Getting started

A first form with validation, built in the order you would build it yourself.

Every formdef app has the same setup: the contexts, a registry that names its widgets, and one config per form. This page builds them one at a time, ending with a form that validates and submits.

The contexts

TanStack Form needs two React contexts. Create them once per app, in a file of their own:

form/context.ts
'use client'

import { createFormHookContexts } from '@tanstack/react-form'

export const { , , useFieldContext, useFormContext } =
  createFormHookContexts()

'use client' is required: the contexts are React contexts, which a server component cannot create. They live apart from the registry on purpose. A form component you register later reads the form through useFormContext(); taking it from the registry, which imports that component, would make the two files import each other.

A widget

A widget is a React component that receives the field as a prop. FieldOf<string> says which value it serves:

form/text-field.tsx
import { type ,  } from '@formdef/core'

type  = <string> & { : string }

export const  = ({ ,  }: ) => {
  const { , , ,  } = ()

  return (
    <div>
      <label htmlFor={.}>{}</label>
      <input
        {...}
        value={.state.}
        onChange={() => .(..)}
      />
      { && . > 0 && <p id={}>{.(' ')}</p>}
    </div>
  )
}
  • field is TanStack's own field: state.value, handleChange, state.meta, unchanged.
  • label is the widget's own prop. A config passes it through props, like any other.
  • fieldParts does the wiring: an id unique to the form, the name, the blur handler and the aria attributes that tie the input to its error. isErrorDue turns true once the field was edited and left, or the form was submitted.

The registry

The registry binds the contexts and the widgets under names. Everything you build forms with comes from it:

form/registry.tsx
'use client'

import {  } from '@formdef/core'

import { ,  } from './context'
import {  } from './text-field'

export const { createForm, Fields,  } = ({
  ,
  ,
  : { :  },
})

The keys of widgets are the names a config uses; they are yours to choose.

A form

The config names a schema, the default values and a widget per field. createForm turns it into a component:

sign-in-form.tsx
'use client'

import {  } from 'zod'

import { createForm } from './form/registry'

const  = .({
  : .('Enter an email address'),
  : .().(8, 'Use at least 8 characters'),
})

export const  = createForm({
  ,
  : { : '', : '' },
  fields: {
    email: { : 'text', : { : 'Email' } },
    password: { : 'text', : { : 'Password' } },
  },
  onSubmit: ({  }) => {
    .()
  },
})

SignInForm is a client component; a server component renders <SignInForm /> like any other. onSubmit runs only once the schema accepts the values.

A submit button

The form has no button yet: buttons are components, and components come from you. Register one under formComponents. It reads the form through useFormContext() from the contexts file:

form/submit-button.tsx
import { useFormContext } from './context'

export const  = ({  }: { : string }) => {
  const form = useFormContext()

  return (
    <form.Subscribe selector={(state) => state.}>
      {() => (
        <button type="submit" aria-disabled={}>
          {}
        </button>
      )}
    </form.Subscribe>
  )
}

Add formComponents: { SubmitButton } to the registry, then place the button with a layout. A layout decides the order and what appears at all:

export const  = createForm({
  ,
  : { : '', : '' },
  fields: {
    email: { : 'text', : { : 'Email' } },
    password: { : 'text', : { : 'Password' } },
  },
  : ['email', 'password', { : 'SubmitButton', : { : 'Sign in' } }],
})

Values from a hook

createForm runs once, at module level, so the config cannot close over a hook's result: a mutation, a router, a toast. Those go into the instance through options:

export const  = () => {
  const  = ()

  return (
    <
      ={{
        onSubmit: async ({  }) => {
          await .()
        },
      }}
    />
  )
}

options takes TanStack's form options and wins over the config key by key. validators and listeners merge per key, so the instance adds to them without dropping the config's.

What the compiler checks

Each of these mistakes stops the build, with the message at the line that makes it.

A default value of the wrong type:

export const  = createForm({
  ,
  : { email: 0, password: '' },
Type '""' is not assignable to type 'never'.
Type 'number' is not assignable to type 'never'.
fields: { email: { : 'text', : { : 'Email' } } }, })

A prop the widget does not take:

export const  = createForm({
  ,
  : { : '', : '' },
  fields: { email: { : 'text', : { : 'Email', rows: 3 } } },
Type '3' is not assignable to type '3 & UnknownKey<"rows">'. Type 'number' is not assignable to type 'UnknownKey<"rows">'.
})

A field the schema does not have:

export const  = createForm({
  ,
  : { : '', : '' },
  fields: { pasword: { : 'text', : { : 'Password' } } },
Object literal may only specify known properties, but 'pasword' does not exist in type '{ email?: object; password?: object; } & LevelFields<PartsWithHandle<OwnParts<{ readonly text: (_: FieldOf<string> & { label: string; }) => null; }, Record<never, never>, Record<never, never>>, ..., never>, ..., ...> & FieldsKnown<...>'. Did you mean to write 'password'?
})

Widgets and the type check lists everything the check covers, and where it ends.