Form Context
Foundation contexts and consumer hooks for building type-safe TanStack Form components via createFormHook.
npx shadcn@latest add @shuip/tsf-form-context
pnpm dlx shadcn@latest add @shuip/tsf-form-context
bun x shadcn@latest add @shuip/tsf-form-context
'use client';
import { createFormHookContexts } from '@tanstack/react-form';
export const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts();
The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.
form-context exports the React contexts and consumer hooks produced by TanStack Form's createFormHookContexts(). It is the foundation for the context-bound composition pattern: reusable field and form components read their state from React context instead of receiving the form instance as a prop.
This sidesteps the variance problem that arises when ReactFormApi's eleven validator generics need to cross a component boundary — the same problem that forces form={form as any} casts in the prop-drilling pattern.
What you install
// components/ui/shuip/tanstack-form/form-context.tsx
'use client';
import { createFormHookContexts } from '@tanstack/react-form';
export const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts();Four exports:
fieldContext,formContext— pass these tocreateFormHookin your project setup.useFieldContext<T>()— call inside a field component to readfield(state, errors, handlers). The generic asserts the field value type.useFormContext()— call inside a form-level component (submit buttons, layout chrome) to read the form instance.
Project setup
Create a lib/form.ts in your project that calls createFormHook once, binding every field and form component you want available globally.
// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { TextField } from '@/components/ui/my-text-field';
import { SubmitButton } from '@/components/ui/my-submit-button';
export const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldContext,
formContext,
fieldComponents: { TextField },
formComponents: { SubmitButton },
});Consumer usage:
import { useAppForm } from '@/lib/form';
import { z } from 'zod';
const schema = z.object({ username: z.string().min(3) });
function MyForm() {
const form = useAppForm({
defaultValues: { username: '' },
validators: { onChange: schema },
onSubmit: ({ value }) => console.log(value),
});
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<form.AppField name='username' children={(field) => <field.TextField label='Username' />} />
<form.AppForm><form.SubmitButton>Submit</form.SubmitButton></form.AppForm>
</form>
);
}The name is autocompleted from defaultValues. Validators accept any standard schema (Zod, Valibot, ArkType) or a plain function.
Building a field component
A field component consumes the field via useFieldContext. It never receives form or name as a prop — both come from the surrounding <form.AppField>.
'use client';
import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { Input } from '@/components/ui/input';
export function TextField({ label }: { label: string }) {
const field = useFieldContext<string>();
return (
<label>
{label}
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={!field.state.meta.isValid}
/>
</label>
);
}Example
Default
'use client';
import { createFormHook } from '@tanstack/react-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
fieldContext,
formContext,
useFieldContext,
useFormContext,
} from '@/components/ui/shuip/tanstack-form/form-context';
function TextField({ label }: { label: string }) {
const field = useFieldContext<string>();
const { isValid, errors } = field.state.meta;
return (
<Field data-invalid={!isValid}>
<FieldLabel htmlFor={field.name}>{label}</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={!isValid}
/>
{!isValid && (
<FieldError errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))} />
)}
</Field>
);
}
function SubmitButton({ children }: { children: React.ReactNode }) {
const form = useFormContext();
return (
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<Button type='submit' disabled={!canSubmit}>
{isSubmitting ? 'Submitting…' : children}
</Button>
)}
</form.Subscribe>
);
}
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { TextField },
formComponents: { SubmitButton },
});
const schema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
});
export default function FormContextExample() {
const form = useAppForm({
defaultValues: { username: '' },
validators: { onChange: schema },
onSubmit: async ({ value }) => {
alert(JSON.stringify(value, null, 2));
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='flex flex-col gap-3'
>
<form.AppField name='username' children={(field) => <field.TextField label='Username' />} />
<form.AppForm>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
</form>
);
}
Exports
Prop
Type