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.
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
- 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. - Type the key with
InjectionKey<T>.inject(FormKey)is then typed, and a missing provider is detectable (it returnsundefined). - Scope sections with a prefix. A section provides a new prefix that its fields inherit, so the same
AddressSectionworks undershipping.andbilling.without knowing where it lives. - Resolve paths in the field hook. Fields pass a local name;
useFormFieldjoins it with the inherited prefix. Error paths from the schema (shipping.postcode) match without mapping, as in normalizing nested field error paths. - 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.
- Unregister on unmount.
registerreturns an unregister function; call it inonBeforeUnmountso conditional sections do not leave stale focus targets.
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.
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.