Migrate from TanStack Form

This guide walks you through migrating your React forms from TanStack Form to Formisch. It explains the mental-model shift, shows the same form in both libraries side by side, breaks the migration into small steps, and maps common TanStack Form APIs to their Formisch equivalents.

Migrating is a rewrite of the form layer, not a drop-in replacement. The component structure often stays similar, but the way you define types, validation, and state access changes. The good news: TanStack Form supports Standard Schema validators, so if you already validate with Valibot, your schemas carry over unchanged.

Both libraries can coexist in the same application without conflicts. You can migrate one form at a time and remove @tanstack/react-form once the last form has been converted. Since both libraries export a hook named useForm, the import path determines which one a component uses.

Key differences

TanStack Form starts from a state container: you declare defaultValues, TypeScript infers the form's types from them, and you attach validators to the form or to individual fields, each with its own trigger like onChange, onBlur, or onSubmit. Reactivity is subscription-based: you opt into re-renders with form.Subscribe or useStore selectors.

Formisch starts from a schema: a single Valibot schema is the source of types, validation rules, and form structure at once. There is no defaultValues object to keep aligned with your types and no per-field validator configuration. Validation always parses the entire form against the schema, so cross-field rules work without extra wiring, and its timing is controlled form-wide with the validate and revalidate config. Reactivity is built on fine-grained signals wired into React's render cycle, so you read state directly from the form store and only the components that depend on it re-render.

In practice, the migration boils down to these changes:

  • Types: Inferred from the schema instead of from defaultValues.
  • Validation: Defined once in the schema instead of spread across validators configs; timing moves from per-validator triggers to the form-wide validate and revalidate options.
  • Field addressing: Path arrays like ['todos', 0, 'label'] instead of interpolated string names like `todos[${index}].label`.
  • Field binding: Spreading field.props instead of manually wiring value, onChange, and onBlur.
  • State access: Reading the form store directly instead of subscribing with selectors.
  • Errors: Plain string arrays in field.errors instead of issue objects in field.state.meta.errors.

Side-by-side example

The following login form validates an email and a password and logs the values on submission. Both versions use the exact same Valibot schema, which TanStack Form accepts as a Standard Schema validator.

TanStack Form

import { useForm } from '@tanstack/react-form';
import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.')
  ),
  password: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your password.'),
    v.minLength(8, 'Your password must have 8 characters or more.')
  ),
});

export default function LoginPage() {
  const form = useForm({
    defaultValues: {
      email: '',
      password: '',
    },
    validators: {
      onChange: LoginSchema,
    },
    onSubmit: async ({ value }) => {
      console.log(value);
    },
  });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        event.stopPropagation();
        form.handleSubmit();
      }}
    >
      <form.Field name="email">
        {(field) => (
          <div>
            <input
              name={field.name}
              value={field.state.value}
              onChange={(event) => field.handleChange(event.target.value)}
              onBlur={field.handleBlur}
              type="email"
            />
            {!field.state.meta.isValid && (
              <div>{field.state.meta.errors[0]?.message}</div>
            )}
          </div>
        )}
      </form.Field>
      <form.Field name="password">
        {(field) => (
          <div>
            <input
              name={field.name}
              value={field.state.value}
              onChange={(event) => field.handleChange(event.target.value)}
              onBlur={field.handleBlur}
              type="password"
            />
            {!field.state.meta.isValid && (
              <div>{field.state.meta.errors[0]?.message}</div>
            )}
          </div>
        )}
      </form.Field>
      <form.Subscribe selector={(state) => state.isSubmitting}>
        {(isSubmitting) => (
          <button type="submit" disabled={isSubmitting}>
            Login
          </button>
        )}
      </form.Subscribe>
    </form>
  );
}

Formisch

import { Field, Form, useForm } from '@formisch/react';
import type { SubmitHandler } from '@formisch/react';
import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.')
  ),
  password: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your password.'),
    v.minLength(8, 'Your password must have 8 characters or more.')
  ),
});

export default function LoginPage() {
  const loginForm = useForm({
    schema: LoginSchema,
  });

  const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
    console.log(values);
  };

  return (
    <Form of={loginForm} onSubmit={submitForm}>
      <Field of={loginForm} path={['email']}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input} type="email" />
            {field.errors && <div>{field.errors[0]}</div>}
          </div>
        )}
      </Field>
      <Field of={loginForm} path={['password']}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input} type="password" />
            {field.errors && <div>{field.errors[0]}</div>}
          </div>
        )}
      </Field>
      <button type="submit" disabled={loginForm.isSubmitting}>
        Login
      </button>
    </Form>
  );
}

Notice what disappeared: the defaultValues object (required string fields start as '' automatically), the manual value, onChange, and onBlur wiring (replaced by spreading field.props), the event.preventDefault() boilerplate (the Form component handles it), and the form.Subscribe wrapper around the submit button (loginForm.isSubmitting is read directly). One timing difference: the TanStack Form version validates on every change from the start, while Formisch by default waits until the first submit and then revalidates on every input. Pass validate: 'input' to useForm if you want to match the TanStack Form behavior.

Migration steps

Install Formisch

Install Formisch and Valibot alongside your existing setup. Both form libraries can run in the same app during the migration.

npm install @formisch/react valibot

See the installation guide for other package managers and TypeScript requirements.

Move validation into a Valibot schema

If your form already passes a Valibot schema to validators, you can reuse it as is. If you use another Standard Schema library like Zod, or hand-written validator functions attached to fields, rewrite the rules as a single Valibot schema. Each field-level validator becomes a validation rule in the schema:

import * as v from 'valibot';

// Before: onChange: ({ value }) => (!value ? 'Please enter your email.' : undefined)
// After: defined once in the schema
const LoginSchema = v.object({
  email: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.')
  ),
  password: v.pipe(v.string(), v.nonEmpty(), v.minLength(8)),
});

Define the fields in the same order they appear in your form, because Formisch focuses the first field with an error after a failed submission. See the define your form guide for details.

Replace the form setup

Swap the useForm import from @tanstack/react-form to @formisch/react and pass your schema instead of defaultValues and validators. The onSubmit option moves out of the hook (see below). If your defaultValues contained real data instead of empty placeholders, pass it as initialInput:

import { useForm } from '@formisch/react';

const loginForm = useForm({
  schema: LoginSchema,
  initialInput: {
    email: 'user@example.com',
  },
});

Required string fields start as '' automatically, so you no longer need to list every field just to initialize it. See the create your form guide for all configuration options.

Replace field bindings

Replace each form.Field with the Field component. String names become path arrays, and the manual event wiring becomes a spread of field.props:

import { Field } from '@formisch/react';

<Field of={loginForm} path={['email']}>
  {(field) => (
    <div>
      <input {...field.props} value={field.input} type="email" />
      {field.errors && <div>{field.errors[0]}</div>}
    </div>
  )}
</Field>;

field.state.value becomes field.input, and field.state.meta.errors becomes field.errors, which is either null or a non-empty array of strings, so no .message access is needed. For nested fields, name="user.email" becomes path={['user', 'email']} with full autocompletion from your schema. If you extracted fields into their own components, use the useField hook instead, as shown in the add form fields guide.

Update submission handling

Replace the native <form> element and its handleSubmit boilerplate with the Form component. Your onSubmit option becomes a handler passed to Form, and it receives the validated output of your schema, fully typed by SubmitHandler:

import { Form } from '@formisch/react';
import type { SubmitHandler } from '@formisch/react';

const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
  await fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify(values),
  });
};

<Form of={loginForm} onSubmit={submitForm}>
  {/* fields */}
  <button type="submit" disabled={loginForm.isSubmitting}>
    Login
  </button>
</Form>;

event.preventDefault() is called automatically, and the handler only runs when the form input passes your schema. See the handle submission guide for async patterns and submitting without a <form> element.

API mapping

TanStack FormFormisch
useForm({ defaultValues, validators })useForm with schema
defaultValuesinitialInput (optional, types come from the schema)
onSubmit option of useFormonSubmit prop of Form
<form> element + form.handleSubmit()Form component
form.Field with name="user.email"Field with path={['user', 'email']}
field.state.valuefield.input
field.handleChange, field.handleBlurSpread field.props, or field.onChange for custom inputs
field.state.meta.errorsfield.errors
field.state.meta.isTouchedfield.isTouched
field.state.meta.isDirtyfield.isEdited (see below)
field.state.meta.isDefaultValue!field.isDirty (see below)
form.Subscribe, useStore(form.store)Read the form store directly (e.g. form.isSubmitting, form.isDirty)
state.canSubmitNo direct equivalent (see below)
form.state.valuesgetInput
form.setFieldValue(name, value)setInput
form.reset()reset
form.Field with mode="array"FieldArray or useFieldArray
pushValue, insertValueinsert
removeValue, moveValueremove, move
swapValues, replaceValueswap, replace
createFormHook, useAppFormNo equivalent needed (see below)
asyncDebounceMsNo direct equivalent (see below)

A few entries deserve a short note:

  • state.canSubmit: Formisch does not compute a combined "can submit" flag. The idiomatic pattern is to keep the submit button enabled and let submission trigger validation, which focuses the first invalid field. If you want a validity-based flag, form.isValid reflects the result of the last validation run. With the default validate: 'submit' timing it stays true until the first submit, so combine it with validate: 'initial' if you want TanStack-like behavior.
  • Dirty state is split differently. TanStack Form's isDirty stays true once a field was changed, even if the value is reverted. That matches Formisch's isEdited. Formisch's isDirty compares the current input against the initial input and turns back off when they match, like TanStack Form's isDefaultValue inverted.
  • createFormHook and useAppForm: These exist in TanStack Form to pre-bind typed field components and share form configuration. Formisch does not need them, since every hook and component is already fully typed by your schema. The role of pre-bound field components is filled by regular input components.
  • asyncDebounceMs: Formisch has no built-in debounce option. Async rules live in the schema via v.pipeAsync and v.checkAsync, and form.isValidating is true while they run. See the validation guide.

Common patterns

Form state

In TanStack Form you subscribe to state with a selector to control re-renders, either through form.Subscribe (as with the submit button in the side-by-side example above) or with the useStore hook in component logic. In Formisch you read the form store directly. The store is built on fine-grained signals wired into React's render cycle, so components re-render only when the properties they read change, without selectors:

<button type="submit" disabled={loginForm.isSubmitting}>
  {loginForm.isSubmitting ? 'Submitting...' : 'Login'}
</button>

The form store exposes isSubmitting, isSubmitted, isValidating, isTouched, isEdited, isDirty, isValid, and errors. To read field values in component logic, use the getInput method, which also stays reactive inside components.

Validation timing

TanStack Form couples timing to each validator: the key you register a rule under (onChange, onBlur, onSubmit, onMount) decides when it runs. In Formisch, the rules live in the schema and timing is configured once for the whole form:

const loginForm = useForm({
  schema: LoginSchema,
  validate: 'blur',
  revalidate: 'input',
});

The validate option controls when a field is first validated ('initial', 'touch', 'input', 'change', 'blur', or 'submit'), and revalidate controls when it is checked again after it has an error or the form was submitted. As a rough mapping, validators.onSubmit corresponds to validate: 'submit' (the default), validators.onBlur to validate: 'blur', validators.onChange to validate: 'input', and validators.onMount to validate: 'initial'. Unlike TanStack Form, every validation pass parses the entire schema, so cross-field rules stay correct no matter which field triggered it. The validation guide covers this in depth, including how to show errors only at the right time.

Field arrays

In TanStack Form you set mode="array" on a field, map over field.state.value, and address items with interpolated string names:

<form.Field name="todos" mode="array">
  {(todosField) => (
    <div>
      {todosField.state.value.map((_, index) => (
        <form.Field key={index} name={`todos[${index}].label`}>
          {(field) => (
            <input
              value={field.state.value}
              onChange={(event) => field.handleChange(event.target.value)}
            />
          )}
        </form.Field>
      ))}
      <button type="button" onClick={() => todosField.pushValue({ label: '' })}>
        Add todo
      </button>
    </div>
  )}
</form.Field>

In Formisch you use the FieldArray component and standalone methods. The items array contains stable unique IDs to use as keys, so React correctly tracks items when they are added, moved, or removed:

import { Field, FieldArray, insert } from '@formisch/react';

<FieldArray of={todoForm} path={['todos']}>
  {(fieldArray) => (
    <div>
      {fieldArray.items.map((item, index) => (
        <Field key={item} of={todoForm} path={['todos', index, 'label']}>
          {(field) => <input {...field.props} value={field.input} />}
        </Field>
      ))}
      <button
        type="button"
        onClick={() =>
          insert(todoForm, { path: ['todos'], initialInput: { label: '' } })
        }
      >
        Add todo
      </button>
    </div>
  )}
</FieldArray>;

The insert, remove, move, swap, and replace methods cover the operations of pushValue, removeValue, moveValue, swapValues, and replaceValue. For example, todosField.removeValue(index) becomes remove(todoForm, { path: ['todos'], at: index }). Rules like a minimum or maximum number of items move into the schema, for example v.pipe(v.array(…), v.maxLength(10)). See the field arrays guide for the full picture.

Reset

Where TanStack Form offers form.reset() to restore defaultValues, Formisch provides the reset method, which can also target individual fields or update the baseline:

import { reset } from '@formisch/react';

// Reset the entire form to its initial input
reset(loginForm);

// Reset a single field
reset(loginForm, { path: ['email'] });

// Reset with a new initial input (e.g. refreshed server data)
reset(loginForm, { initialInput: { email: 'jane@example.com' } });

Passing initialInput updates the dirty-tracking baseline, which is the right tool when server data changes. Use setInput instead when you only want to change the current value.

Special inputs

In TanStack Form, checkboxes, selects, and files are wired manually, for example with field.handleChange(event.target.checked). In Formisch, spreading field.props covers all native input types; you only set the state attribute that matches the input:

<Field of={form} path={['cookies']}>
  {(field) => (
    <label>
      <input {...field.props} type="checkbox" checked={field.input} />
      Yes, I want cookies
    </label>
  )}
</Field>

See the special inputs guide for radio groups, multi-selects, and file inputs. One caveat carried over from the DOM: event handlers read strings, so fields with a non-string schema like v.number() or v.date() need to be controlled, as explained in the controlled fields guide. This replaces patterns like field.handleChange(event.target.valueAsNumber).

Next steps

You now have everything you need to convert your forms one by one. To deepen your understanding of the Formisch side, read the define your form and add form fields guides, explore the form methods for programmatic control, and check out the TypeScript guide to get the most out of schema-based type inference.

Contributors

Thanks to all the contributors who helped make this page better!

  • GitHub profile picture of @fabian-hiller

Partners

Thanks to our partners who support the project ideally and financially.

Sponsors

Thanks to our GitHub sponsors who support the project financially.

  • GitHub profile picture of @vasilii-kovalev
  • GitHub profile picture of @UpwayShop
  • GitHub profile picture of @ruiaraujo012
  • GitHub profile picture of @hyunbinseo
  • GitHub profile picture of @nickytonline
  • GitHub profile picture of @kibertoad
  • GitHub profile picture of @caegdeveloper
  • GitHub profile picture of @Thanaen
  • GitHub profile picture of @bmoyroud
  • GitHub profile picture of @ysknsid25
  • GitHub profile picture of @dslatkin