Large Vue forms are split into section components — address, payment, contacts — nested two or three levels deep, and teams end up either drilling form, errors and touched through every level as props, or moving the form into Pinia so any component can reach it, which leaks one form’s state into global scope and breaks when the same form appears twice.

provide/inject sits between those extremes: the form component provides its state once, and any descendant can inject it, scoped to that form instance. The Vue composition API form adapters topic defines the form composable; this page shows how to share it with nested sections safely, including sections that are reused under different path prefixes.


Context and prerequisites

Three ways to get form state into a nested section:

  • Props — explicit and traceable, but every intermediate component must forward values, errors and handlers, even ones that do not use them.
  • A global store (Pinia) — reachable from anywhere, but one store instance per form type means two instances of the form share state, and the state outlives the form unless reset carefully. Pinia fits when state must survive navigation, as in syncing Vue form state with Pinia.
  • Provide/inject — scoped to the providing component’s subtree, created and destroyed with the form, typed through an InjectionKey.

For most forms, provide/inject is the right default for sharing within one form.

Three ways to reach a nested section Props are explicit but require every intermediate component to forward form values, errors and handlers. A global Pinia store is reachable anywhere but shares state between two instances of the same form and outlives the form unless reset. Provide and inject is scoped to the providing form's subtree, is created and destroyed with the form, and is typed through an InjectionKey. Props Explicit, traceable. Every level forwards everything. Global store Reachable anywhere. Two instances share state; outlives the form. Provide / inject Scoped to this form's subtree. Lives and dies with the form. Typed via InjectionKey.

The core pattern: a typed form context and prefixed section scopes

// form-context.ts
import { inject, provide, type InjectionKey, type Ref } from "vue";

export interface FormContext {
  getValue(path: string): unknown;
  setValue(path: string, value: unknown): void;
  error(path: string): Ref<string | undefined>;           // computed per path
  blur(path: string): void;
  register(path: string, el: () => HTMLElement | null): () => void;   // for focus-on-error
}

const FormKey: InjectionKey<FormContext> = Symbol("form");
const PrefixKey: InjectionKey<string> = Symbol("form-prefix");

export function provideForm(ctx: FormContext) {
  provide(FormKey, ctx);
  provide(PrefixKey, "");
}

// A section scopes its descendants under a path prefix: "shipping." or "billing."
export function provideSection(name: string) {
  const parent = inject(PrefixKey, "");
  provide(PrefixKey, parent ? `${parent}${name}.` : `${name}.`);
}

// Fields call this; they never see the prefix logic.
export function useFormField(name: string) {
  const form = inject(FormKey);
  if (!form) throw new Error(`useFormField("${name}") used outside a form`);
  const path = inject(PrefixKey, "") + name;
  return {
    path,
    get value() { return form.getValue(path); },
    set value(v: unknown) { form.setValue(path, v); },
    error: form.error(path),
    blur: () => form.blur(path),
    register: (el: () => HTMLElement | null) => form.register(path, el),
  };
}
<!-- AddressSection.vue — reusable for shipping and billing -->
<script setup lang="ts">
import { provideSection } from "./form-context";
const props = defineProps<{ name: "shipping" | "billing"; legend: string }>();
provideSection(props.name);   // every field below resolves to "shipping.street" etc.
</script>
<template>
  <fieldset>
    <legend>{{ legend }}</legend>
    <FormTextField name="street" label="Street" autocomplete="street-address" />
    <FormTextField name="city" label="Town or city" autocomplete="address-level2" />
    <FormTextField name="postcode" label="Postcode" autocomplete="postal-code" />
  </fieldset>
</template>
<!-- Checkout.vue -->
<AddressSection name="shipping" legend="Shipping address" />
<AddressSection name="billing" legend="Billing address" />

Step-by-step walkthrough

  1. Provide an interface, not raw reactive state. Exposing getValue/setValue/error(path) instead of the whole reactive object keeps descendants from mutating state in ways the form does not see, and lets the form change its internals freely.
  2. Type the key with InjectionKey<T>. inject(FormKey) is then typed, and a missing provider is detectable (it returns undefined).
  3. Scope sections with a prefix. A section provides a new prefix that its fields inherit, so the same AddressSection works under shipping. and billing. without knowing where it lives.
  4. Resolve paths in the field hook. Fields pass a local name; useFormField joins it with the inherited prefix. Error paths from the schema (shipping.postcode) match without mapping, as in normalizing nested field error paths.
  5. Register elements for focus. Each field registers a getter for its input; the form can then move focus to the first invalid path in document order after submit, as in moving focus to the first invalid field.
  6. Unregister on unmount. register returns an unregister function; call it in onBeforeUnmount so conditional sections do not leave stale focus targets.
How a nested field resolves its path The checkout form calls provideForm, which provides the form context and an empty prefix. The shipping AddressSection calls provideSection with shipping, injecting the empty parent prefix and providing shipping dot. The postcode field calls useFormField with postcode, injects the form context and the shipping prefix, and resolves the full path shipping.postcode, which is the same path the schema uses for its errors. Checkout provides FormKey → context PrefixKey → "" One context per form instance. AddressSection provides PrefixKey → "shipping." Inherits the parent prefix and extends it. Field injects useFormField("postcode") Resolves to "shipping.postcode" — the schema's error path.

Section-level state: validity and dirtiness per section

Sections are also a natural unit for summaries. A long checkout benefits from showing “Shipping address — 1 problem” in a sidebar or accordion header, and a wizard needs to know whether a section is complete before moving on. Because every field under a section resolves to the section’s prefix, the form can answer these questions by filtering its error and dirty maps by prefix: errors whose path starts with shipping. belong to the shipping section. Expose a small useSectionStatus() helper that injects the prefix and returns computed counts, rather than having sections track their own children.

This keeps the form as the single owner of state. Sections become pure views over a slice of it, which is what lets the same AddressSection appear twice on a page — once for shipping, once for billing — with independent statuses and no state of its own to reset. It also means a section that unmounts, such as a collapsed optional block, cannot take its errors with it by accident; they stay in the form until the relevance rules say otherwise.


Failure modes and edge cases

1. Injecting outside a provider

A field rendered outside any form (in a storybook, or a component reused elsewhere) gets undefined. Throw a clear error, or provide a default no-op context in isolation environments.

2. Providing reactive state directly

provide("form", reactive(values)) lets any descendant write form.city = "x" and bypass validation, dirty tracking and change events. Provide functions; if you must provide state, wrap it with readonly().

3. Reactivity loss from destructuring

Destructuring a reactive object returned from inject breaks reactivity. The pattern above returns getters and computed refs; if you return plain objects, use toRefs before destructuring.

4. Two forms on one page

Each form component provides its own context, so nested fields bind to their nearest form. This is exactly what a global store gets wrong; confirm it with two instances of the same form side by side.

5. Sections that move between forms

A section in a modal teleported to body still injects from its component ancestors, not its DOM position, so it keeps working with Teleport. It stops working if the modal is mounted by a separate app instance.

Troubleshooting injected form state A field that throws used outside a form is missing a provider; wrap it or provide a default context in isolated environments. A field that does not update after inject lost reactivity through destructuring; use computed refs or toRefs. Two forms that share values are using a global store instead of provide; move the state into the form's provide. Errors that do not appear on nested fields have mismatched paths; resolve paths with the section prefix. Symptom Cause Fix "used outside a form" error no provider above wrap in a form; default in stories field does not update destructured reactive computed refs / toRefs two forms share values global store provide per form instance nested errors never show path mismatch resolve with the section prefix

Verification checklist


Frequently Asked Questions

Is provide/inject "implicit" in a bad way?

It is implicit compared with props, which is why it should carry a narrow, typed interface rather than arbitrary state. Used for one well-defined dependency — the enclosing form — it removes noise without hiding meaningful data flow.

Does provide/inject cause extra re-renders?

No. Injection itself is not reactive; what you inject may be. Because fields read their own path through computed refs, a change to one path triggers only the components that read it, in line with Vue’s fine-grained reactivity.

How do I test a section in isolation?

Mount it with global.provide in Vue Test Utils, supplying a fake form context whose getValue/setValue are backed by a plain object. Assert that the section’s fields read and write the expected prefixed paths.


Related

← Vue Composition API Form Adapters