Form Context

Foundation contexts and consumer hooks for building type-safe TanStack Form components via createFormHook.

npx shadcn@latest add https://shuip.plvo.dev/r/tsf-form-context.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-form-context.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-form-context.json
'use client';
import { createFormHookContexts } from '@tanstack/react-form';
export const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts();
Loading...

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 to createFormHook in your project setup.
  • useFieldContext<T>() — call inside a field component to read field (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

Loading...
'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

On this page