Form context in nested components

Splitting a form across components? Don't prop-drill register / errors / handleSubmit through every layer. Call injectForm() in any descendant and get the same handle back.

The common case — ambient resolution

Parent owns the form:

<!-- SignupForm.vue -->
<script setup lang="ts">
  import { useForm } from 'attaform/zod'
  import { z } from 'zod'

  interface Form {
    email: string
    profile: { name: string; age: number }
  }

  const schema = z.object({
    email: z.email(),
    profile: z.object({ name: z.string(), age: z.number() }),
  })

  // Anonymous useForm — ambient mode. Pass `key: 'signup'` instead
  // when descendants should reach it via `injectForm<Form>('signup')`.
  const { handleSubmit } = useForm<Form>({ schema })
  const onSubmit = handleSubmit(async (values) => {
    await api.post('/signup', values)
  })
</script>

<template>
  <form @submit.prevent="onSubmit">
    <EmailRow />
    <ProfileGroup />
    <button>Sign up</button>
  </form>
</template>

Any descendant grabs the same form:

<!-- EmailRow.vue -->
<script setup lang="ts">
  import { injectForm } from 'attaform/zod'

  interface Form {
    email: string
    profile: { name: string; age: number }
  }

  const { register, errors } = injectForm<Form>()
</script>

<template>
  <label>Email</label>
  <input v-register="register('email')" type="email" />
  <small v-if="errors.email?.[0]">
    {{ errors.email[0].message }}
  </small>
</template>

You supply the Form generic — Vue's injection system erases it, so the library can't recover your shape on your behalf. Other than that, injectForm<Form>() returns an object type-identical to useForm's return.

Reaching a form that isn't an ancestor

Floating save buttons, sidebar status widgets, anything in a different branch of the component tree:

<!-- FloatingSaveButton.vue (anywhere in the app) -->
<script setup lang="ts">
  import { injectForm } from 'attaform/zod'

  interface Form {
    /* … */
  }

  const { meta, handleSubmit } = injectForm<Form>('signup')
</script>

<template>
  <button :disabled="!meta.isDirty || meta.isSubmitting" @click="handleSubmit(onSave)()">
    Save
  </button>
</template>

Pass the same key you passed to useForm({ key: 'signup' }). If no form is registered under that key when the component mounts, you get a clear error naming the missing key.

Do I need to pass a key to useForm?

The two resolution modes are cleanly split:

  • Anonymous (no key) → ambient access. useForm({ schema }) fills the parent's ambient slot. Any descendant's injectForm<Form>() (no key) resolves to it; closest ancestor wins when nested.
  • Keyed (key: 'x') → explicit access only. useForm({ schema, key: 'x' }) registers the form under 'x' but does NOT fill the ambient slot. Descendants reach it via injectForm<Form>('x'), not via the no-key form.

Skip key for single-component one-off forms (login modal, settings panel). Supply one when you want cross-component lookup, multi-call-site shared state, a stable persistence default, or a legible DevTools label.

Gotcha: multiple anonymous useForm calls in the same component

Vue's provide/inject is last-write-wins per component. If a parent calls useForm twice without keys, the second overwrites the first in the ambient slot, and descendants using injectForm<Form>() only see the second.

// Parent component
const formA = useForm({ schema: schemaA }) // provides ambient → A
const formB = useForm({ schema: schemaB }) // provides ambient → B (overwrites A)
// Descendants' injectForm<Form>() reads B. A is unreachable via ambient.

The runtime emits a dev-mode console.warn lazily — when (and only when) a descendant actually consumes the ambient slot via injectForm<Form>() with no key. The warning lists each anonymous useForm() call by source frame so you can navigate to the offending sites.

Fix — give each form a key (which removes them from the ambient slot entirely) and look them up explicitly:

useForm({ schema: schemaA, key: 'a' })
useForm({ schema: schemaB, key: 'b' })
// Descendants:
const a = injectForm<FormA>('a')
const b = injectForm<FormB>('b')

Mixing modes is fine — keyed forms don't interfere with an ambient sibling. A parent with three keyed forms plus one anonymous form produces no warning; the descendant's injectForm<F>() unambiguously resolves to the (only) anonymous one.

…or split the two anonymous forms into separate components, so each owns the ambient slot of its own subtree.

Lifetime

Both resolution modes ref-count on the form's registry entry. In practice:

  • The form survives until every component that reached it unmounts.
  • Cleanup is automatic — no explicit dispose call from the consumer.
  • A form accessed only by injectForm(key) stays alive as long as at least one consumer is mounted, even if the original useForm owner unmounted first.

Error messages

injectForm() throws only in two cases:

  • No ambient form — you called injectForm() with no ancestor useForm and no key argument. The error names both resolutions so you can pick either.
  • Key not registered — you called injectForm('key-name') but nothing is registered. The error includes the key value so you can spot typos or mounting-order bugs.

When not to use it

If your form logic fits in one component, stick with useForm directly. injectForm is a small reactive overhead you don't need when there's nothing to share.

Reach for it when field components are reusable across forms, or when a distant component needs read-only status (meta.isDirty, meta.isSubmitting, errors) of a form it doesn't own.