OfficeGest
Form Builder

Escape Hatch: useField

Drop to a headless field binding when a built-in renderer is not enough, plus the dev-mode unmounted-field warning.

Every built-in field type renders through the registry: you give useFormBuilder a fields config, drop a form.Field name="…" (or form.Fields) into the tree, and the form supplies the chrome — label, input, validation messages, accessibility wiring. That covers the vast majority of forms.

When none of the 17 built-in renderers is the right shape — a custom card picker, an inline editor, a widget with its own internal layout — you reach for form.useField. It is the headless escape hatch: it hands you the field's value, setter, resolved errors, and meta flags, and lets you render whatever JSX you like, while the field stays fully wired into the form's validation and submission pipeline.

This page documents that hook and the DEV-only warning that fires when a schema field is never mounted in the rendered tree.


form.useField(name)

form.useField subscribes to one field by name and returns a FieldHandle. It must be called inside <form.Provider> — calling it outside throws, because it has no form store to bind to.

function PriorityPicker({form}: {form: FormApi<typeof schema>}): ReactElement {
    const field = form.useField("priority");

    return (
        <div role="radiogroup" aria-label="Priority">
            {(["low", "medium", "high"] as const).map((level) => (
                <button
                    type="button"
                    key={level}
                    aria-pressed={field.value === level}
                    onClick={() => field.setValue(level)}
                >
                    {level}
                </button>
            ))}
            {field.error ? (
                <p className="text-destructive text-sm">{field.error}</p>
            ) : null}
        </div>
    );
}

The hook is bound to the schema through useFormBuilder, so name autocompletes from the schema keys and field.value carries the precise type of that field.

Errors on the handle are already-resolved strings. The schema validator runs the i18n error map at parse time, so field.error / field.errors are plain display strings — useField never re-resolves them through i18n. Render them directly.


The FieldHandle shape

This is the exact surface returned by form.useField. There are no handleChange / handleBlur methods on the handle — writes go through setValue, and native inputs get wired through inputProps.

MemberTypeWhat it is
valueTValueCurrent field value, typed from the schema.
setValue(value: TValue) => voidWrite a new value into the form store.
errorstring | undefinedFirst resolved error, or undefined when valid.
errorsstring[]All resolved errors. Empty array when valid.
meta.isDirtybooleanValue differs from the field's default.
meta.isTouchedbooleanField has been focused and blurred.
meta.isValidatingbooleanAn async validator for this field is in flight.
inputPropsobjectSpreadable props for a native text input — see below.
type FieldHandle<TValue> = {
    value: TValue;
    setValue: (value: TValue) => void;
    error: string | undefined;
    errors: string[];
    meta: {
        isDirty: boolean;
        isTouched: boolean;
        isValidating: boolean;
    };
    inputProps: {
        name: string;
        value: TValue;
        onChange: (event: ChangeEvent<HTMLInputElement>) => void;
        onBlur: () => void;
    };
};

inputProps is for native text inputs only

inputProps is a convenience for the common case: a custom layout that still wraps a plain <input> or <textarea>. Its onChange reads event.target.value — a string — and writes that into the field. Spreading it onto a native text element gives you a controlled input with no boilerplate.

function InlineNameEditor(): ReactElement {
    const field = form.useField("name");

    return (
        <label className="flex flex-col gap-1">
            <span className="text-sm font-medium">Name</span>
            <input {...field.inputProps} className="rounded-md border px-2 py-1" />
            {field.error ? (
                <span className="text-destructive text-xs">{field.error}</span>
            ) : null}
        </label>
    );
}

inputProps.onChange extracts event.target.value, so the props are only safe to spread onto a native <input> or <textarea>. For any non-text chrome — a toggle, a custom select, a card picker, a slider — do not spread inputProps. Drive the field with value and setValue directly, as in the PriorityPicker example above. Passing a boolean or object through inputProps would mangle it into a string.


useField vs. a custom slot type

Both let you render UI the registry doesn't ship. The discriminator is reuse, not complexity.

Reach for…When
form.useFieldA one-off piece of bespoke chrome inside a single form. The custom control lives next to the form that needs it; no other form will use it.
A slot-contributed field typeA reusable field type you want available across many forms — registered once via the slot system and FieldTypeRegistry declaration merging, then usable as {type: "yourType", …} in any fields config.

If you find yourself copying the same useField widget into a second form, that is the signal to promote it to a slot type. The slot path lives entirely in the contributing module — see the licensePlate worked example in the _development module — and never touches src/components/forms/.


DEV warning: schema fields never mounted

It is easy to add a key to the Zod schema (and therefore to FormFields<T>), then forget to render it anywhere — no form.Field, no form.Fields, no form.useField. The form still carries a default value for that field, so submissions silently include it (usually as undefined) and nothing in the UI complains.

The form builder catches this in development. Each form.Field and form.useField mount registers its name in a cumulative set. After a short window of render stability (one second), the builder diffs the schema's field keys against that set and logs any that were never mounted:

[form:my-form] Schema fields not mounted in the rendered tree: notes, language

This watchdog is DEV-only. The tracking set is allocated only when import.meta.env.DEV is true; in production builds it is never created and the check short-circuits to a no-op — there is no runtime cost and no console output for end users.

A field hidden by a visible predicate (visible: false or a predicate returning false) still counts as mounted and does not trigger a false warning. The form.Field registers its name on mount regardless of the visibility result — a hidden field renders nothing, but the registration effect still fires — so toggling a field off does not make it look "unmounted" to the watchdog.

The fix when you do see the warning is one of:

Render the field. Add a form.Field name="…", include it in a form.Fields list, or bind it with form.useField if it needs bespoke chrome. This is the right fix when the field was simply forgotten.

Remove it from the schema. If the field genuinely should not be part of this form, drop it from the Zod schema and the fields config so the form stops carrying a phantom value.


Putting it together

A custom control bound through useField, living inside the same form.Provider as the registry-rendered fields, submitting as one unit:

const schema = z.object({
    name: z.string().min(1),
    priority: z.enum(["low", "medium", "high"]),
});

const fields: FormFields<typeof schema> = {
    name: {type: "text", label: "Name"},
    priority: {type: "select", label: "Priority", options: [
        {label: "Low", value: "low"},
        {label: "Medium", value: "medium"},
        {label: "High", value: "high"},
    ]},
};

function TaskForm(): ReactElement {
    const form = useFormBuilder({
        formId: "task",
        schema,
        fields,
        defaultValues: {priority: "low"},
        onSubmit: (values) => save(values),
    });

    return (
        <form.Provider>
            <form.Field name="name" />
            <PriorityPicker form={form} />
            <form.SubmitButton>Save</form.SubmitButton>
        </form.Provider>
    );
}

Here name renders through the registry, priority renders through the custom PriorityPicker (which calls form.useField("priority")), and both are validated and collected on submit. Because priority is bound via useField, the DEV warning stays quiet — the field counts as mounted.