skip to content

How would you build a Vue 3 form context so nested field components register with their form through provide() and inject()?

level: seniorimportance: should knowfreq 40%

answer

  1. a Symbol key in its own file
  2. register on setup, unregister on unmount
  3. state readonly, actions provided
  4. fields outside a form

basics

~20 s

The Form provides, under an exported Symbol key, a context of readonly state plus register and unregister functions. Each field injects it in setup, registers itself, and unregisters in onUnmounted; the Form validates and submits through its registry.

solid answer

~50 s

The `Form` component creates its state (values, errors, registered fields) and provides one context object under an exported Symbol key: readonly views of the state plus functions such as `register(name, validate)`, `unregister(name)` and `setValue(name, v)`. Each `FormField` calls `inject(formKey, null)` synchronously in setup; if it gets `null` it throws a clear error or runs standalone, otherwise it registers itself and calls `unregister` in `onUnmounted`, so `v-if`-toggled fields do not leave stale entries. Fields can sit at any depth, inside wrappers and slots, with no prop drilling. Nested forms work naturally because a field injects from its nearest provider. Two Vue specifics: provide must happen synchronously in the Form's setup, and a component cannot inject its own provided context, so a nested Form that wants its parent's context simply injects the same key; its own provide does not shadow it.

code

vue · 10 lines
vue
<template>
  <Form @submit="save">
    <fieldset>
      <GridRow>
        <FormField name="email" required />
      </GridRow>
    </fieldset>
    <FormField v-if="showPhone" name="phone" />
  </Form>
</template>

go deeper

for a junior

Know the shape: the Form provides a context object, and each field injects it in setup instead of receiving it through props.

for a middle

Explain the lifecycle pairing of register in setup with unregister on unmount, and why the provided state should be readonly with mutating functions alongside.

for a senior

Design the context API, handle fields outside a form and nested forms, and explain why a component's inject() sees its parent's provide rather than its own.

for a principal

Decide which parts of the form contract are public for other teams' custom fields, and keep that context small, versioned in one file and documented.

## Why a form is a good fit A form is the classic case for provide/inject. Fields live at arbitrary depth (inside fieldsets, grid wrappers, tabs and slots), yet the form must know every field to validate and submit. Passing a `form` prop through every wrapper would couple layout components to form logic. Instead, the form **provides a context** and each field **injects** it. ## The key and the context Put the key in its own module so the form and its fields share it without a string collision: ```ts // formContext.ts import type { DeepReadonly, Ref } from 'vue' export interface FormContext { values: DeepReadonly<Record<string, unknown>> errors: DeepReadonly<Record<string, string | undefined>> submitting: Readonly<Ref<boolean>> register(name: string, validate: () => string | undefined): void unregister(name: string): void setValue(name: string, value: unknown): void } export const formKey = Symbol('form') ``` (Typing the key itself with `InjectionKey` is a TypeScript topic of its own.) ## The provider: Form ```vue <script setup lang="ts"> import { reactive, ref, readonly, provide } from 'vue' import { formKey, type FormContext } from './formContext' const values = reactive<Record<string, unknown>>({}) const errors = reactive<Record<string, string | undefined>>({}) const submitting = ref(false) const validators = new Map<string, () => string | undefined>() const ctx: FormContext = { values: readonly(values), errors: readonly(errors), submitting: readonly(submitting), register: (name, validate) => validators.set(name, validate), unregister: (name) => { validators.delete(name) delete values[name] delete errors[name] }, setValue: (name, value) => { values[name] = value } } provide(formKey, ctx) </script> ``` Design choices, each for a reason: - **Readonly state**: fields can render values and errors reactively but cannot write them directly. - **Intent-named functions**: every mutation goes through the form, where validation and dirty tracking live. - **A plain `Map` for validators**: nothing renders it, so it does not need to be reactive. ## The injector: FormField ```vue <script setup lang="ts"> import { inject, onUnmounted, computed } from 'vue' import { formKey } from './formContext' const props = defineProps<{ name: string; required?: boolean }>() const form = inject(formKey, null) if (!form) throw new Error('FormField must be used inside a Form') form.register(props.name, () => props.required && !form.values[props.name] ? 'Required' : undefined ) onUnmounted(() => form.unregister(props.name)) const error = computed(() => form.errors[props.name]) </script> ``` The steps, in order: 1. `inject()` runs **synchronously** in setup; inside a callback or timer it would have no component context. 2. The `null` default suppresses the not-found warning so the component can throw its own clearer error (or choose a standalone mode). 3. `register` runs once per field instance; `onUnmounted` removes it, so a field hidden with `v-if` does not keep failing validation. 4. Reads such as `form.errors[props.name]` are reactive because the provided objects are readonly proxies of reactive state. ## Nesting and scoping | Situation | What a field injects | |---|---| | Field inside one Form | that Form's context | | Field inside an inner Form nested in an outer Form | the inner Form's context, the nearest provider | | Inner Form that needs the outer context | it injects `formKey` itself; `inject()` reads from its parent, so it gets the outer one | | Field with no Form above it | `null`, handled explicitly | The third row is a Vue specific: a component's own `provide()` is visible only to its descendants, and `inject()` starts its lookup at the parent. A nested form can therefore read the outer context and provide its own under the same key without conflict, for example to forward its validity upward. ## Why not a module-level store for this A form's state could live in a module-level `reactive()` object that fields import, but that store would be **one per page**: two forms on the same page, or a form inside a dialog over another form, would share values and validators. Providing the context scopes it to **one Form instance and its subtree**, so every form has its own registry and fields always talk to the form that contains them. ## Pitfalls interviewers probe - Providing `values` without `readonly()` and letting fields mutate each other's data. - Registering in `onMounted` but never unregistering, so conditional fields leak. - Calling `provide()` from a watcher or event handler after mount to swap the context; Vue warns and descendants keep the original. - Using the string key `'form'`, which a third-party component could also use. - Relying on props for field names while also looking fields up by DOM order.

  • Why should a Vue form field unregister in onUnmounted rather than rely on the form to notice?
    The form only knows what fields told it. A field removed by `v-if` or a route change leaves its validator in the registry, so the form keeps validating a field the user cannot see. Unregistering on unmount keeps the registry equal to the mounted fields.
  • A nested Form needs to tell the outer Form whether it is valid. How can it reach the outer context?
    It calls `inject(formKey, null)` in its setup. Because `inject()` starts at the parent, it receives the outer form's context even though it provides the same key to its own descendants. It can then register itself with the outer form like a field.

saying these in an interview costs you the question

  • Each field should receive the form as a prop through every wrapper.
  • Registered fields are removed automatically when they unmount.
  • Providing the form's state writable is fine since fields are trusted.
  • A nested Form cannot read the outer Form's context once it provides the same key.
  • inject() can be called later, for example inside the submit handler.