Migrate from React Hook Form

This guide walks you through migrating your forms from React Hook Form to Formisch. It covers the mental-model shift, a complete side-by-side example, a step-by-step migration path, and a mapping of React Hook Form APIs to their Formisch equivalents.

Migrating is a rewrite of the form layer, not a drop-in replacement. The mental model is different: instead of starting from a component and attaching form behavior to it, you start from a Valibot schema and build the component around it. The good news is that forms are naturally isolated units, so the rewrite happens one form at a time.

Both libraries can coexist in the same application without conflicts. You can install Formisch next to React Hook Form, migrate a single form, and remove React Hook Form once the last one is converted. Since both libraries export a hook named useForm, the import path determines which one a component uses.

Key differences

React Hook Form is component-first. You declare a TypeScript generic for your form values, register inputs by string name, and attach validation either as per-field rules or through a resolver. Formisch is schema-first: a single Valibot schema is the source of types, validation rules, and form structure all at once. There is no separate generic to declare and no defaultValues object to keep aligned with your validation.

The most important differences at a glance:

  • Type source: React Hook Form types come from a generic you declare on useForm<FormValues>(). Formisch infers all types, including field paths and the validated output, from the schema.
  • Validation location: React Hook Form spreads validation across register rules, Controller rules, or a resolver. Formisch defines all validation in the schema, next to the structure it validates.
  • Validation timing: React Hook Form uses the mode and reValidateMode options. Formisch uses the equivalent validate and revalidate options with slightly different mode names.
  • Validation scope: React Hook Form validates individual fields as they trigger. Formisch always parses the entire form against the schema in a single pass and distributes the resulting errors to the individual fields. Cross-field rules work without extra wiring, and each field still only re-renders when its own errors change.
  • Reactivity: React Hook Form makes re-render scope your responsibility through watch, useWatch, and the formState Proxy with its destructuring rules. Formisch is built on fine-grained signals, so components automatically re-render when the state they read changes. There is no subscription API to manage.
  • Field binding: React Hook Form registers inputs by string name (register('user.email')). Formisch connects fields through the headless Field component with a type-safe path array (path={['user', 'email']}).

If you already validate with valibotResolver from @hookform/resolvers, you are halfway there: your Valibot schema carries over to Formisch unchanged.

Side-by-side example

The following login form has two validated fields, displays error messages, disables the submit button while submitting, and processes the values on submission. Both versions implement identical behavior.

React Hook Form

import { type SubmitHandler, useForm } from 'react-hook-form';

interface LoginValues {
  email: string;
  password: string;
}

export default function LoginPage() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginValues>({
    defaultValues: { email: '', password: '' },
  });

  const onSubmit: SubmitHandler<LoginValues> = async (values) => {
    // Process the validated form values
    console.log(values);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        type="email"
        {...register('email', {
          required: 'Please enter your email.',
          pattern: {
            value: /^\S+@\S+\.\S+$/u,
            message: 'The email address is badly formatted.',
          },
        })}
      />
      {errors.email && <div>{errors.email.message}</div>}
      <input
        type="password"
        {...register('password', {
          required: 'Please enter your password.',
          minLength: {
            value: 8,
            message: 'Your password must have 8 characters or more.',
          },
        })}
      />
      {errors.password && <div>{errors.password.message}</div>}
      <button type="submit" disabled={isSubmitting}>
        Login
      </button>
    </form>
  );
}

Formisch

import { Field, Form, type SubmitHandler, useForm } 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 onSubmit: SubmitHandler<typeof LoginSchema> = async (values) => {
    // Process the validated form values
    console.log(values);
  };

  return (
    <Form of={loginForm} onSubmit={onSubmit}>
      <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 how the validation rules moved out of the JSX and into the schema, and how the LoginValues interface disappeared because the schema provides the types. Error messages live on each field store instead of a central errors object.

Migration steps

Install Formisch

Add Formisch and Valibot alongside React Hook Form. Both form libraries can run in parallel during the migration.

npm install @formisch/react valibot

See the installation guide for other package managers.

Move validation into a Valibot schema

Translate your register rules or resolver schema into a single Valibot schema. Each rule has a direct counterpart: required becomes v.nonEmpty(), minLength becomes v.minLength(), and pattern usually becomes a dedicated action like v.email() or v.regex().

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.')
  ),
});

If you already use valibotResolver, reuse your existing schema as-is. Define fields in the same order they appear in your form, because Formisch focuses the first field with an error on a failed submit. The define your form guide covers schema design in detail.

Replace the form setup

Swap React Hook Form's useForm for the Formisch useForm hook. The generic and the resolver disappear, and the schema takes their place. defaultValues becomes initialInput and is optional: required string fields start as an empty string automatically, so you no longer need to list every field just to avoid undefined values.

// Before
import { useForm } from 'react-hook-form';

const { register, handleSubmit, formState } = useForm<LoginValues>({
  resolver: valibotResolver(LoginSchema),
  defaultValues: { email: '', password: '' },
  mode: 'onTouched',
});
// After
import { useForm } from '@formisch/react';

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

The mode and reValidateMode options map to validate and revalidate. See the validation timing section below for the exact mapping.

Replace field bindings

Replace each register call with the Field component. Instead of spreading register('email'), you spread field.props onto the input and bind field.input as its value. Errors move from the central formState.errors object to the field store, so the error display lives right next to the input.

// Before
<>
  <input
    type="email"
    {...register('email', { required: 'Please enter your email.' })}
  />
  {errors.email && <div>{errors.email.message}</div>}
</>
// After
<Field of={loginForm} path={['email']}>
  {(field) => (
    <div>
      <input {...field.props} value={field.input} type="email" />
      {field.errors && <div>{field.errors[0]}</div>}
    </div>
  )}
</Field>

String paths with dots become type-safe path arrays: register('user.email') turns into path={['user', 'email']} and register('todos.0.label') turns into path={['todos', 0, 'label']}. Where you used Controller or useController for individual field components, use the Field component or useField hook instead. The add form fields guide explains both in detail.

Update submission handling

Replace the <form> element wrapped with handleSubmit(onSubmit) by the Form component. Your handler keeps the same shape: it only runs after validation succeeds and receives the validated values, which are now typed as the schema's output.

// Before
<form onSubmit={handleSubmit(onSubmit)}>{/* fields */}</form>

// After
<Form of={loginForm} onSubmit={onSubmit}>{/* fields */}</Form>

Async handlers work the same way, and loginForm.isSubmitting replaces formState.isSubmitting for the loading state. See the handle submission guide for details.

API mapping

React Hook FormFormisch
useForm<T>(config)useForm with schema (types are inferred)
resolver: valibotResolver(schema)schema option
defaultValuesinitialInput option
mode / reValidateModevalidate / revalidate options
register('name')Field component with path={['name']}
Controller / useControlleruseField hook or field.onChange
handleSubmit(onValid)onSubmit prop of Form or handleSubmit
formState.errorsfield.errors per field, getErrors, getDeepErrors
formState.isDirty / dirtyFieldsform.isDirty, field.isDirty, getDirtyInput
formState.touchedFieldsfield.isTouched
formState.isSubmittingform.isSubmitting
formState.isSubmittedform.isSubmitted
formState.isValidform.isValid
formState.isValidatingform.isValidating
watch('name') / useWatchfield.input, getInput
getValues()getInput
setValuesetInput
setError / clearErrorssetErrors
triggervalidate
setFocusfocus
reset / resetFieldreset
useFieldArrayFieldArray component or useFieldArray hook
append / prepend / insertinsert
remove / move / swap / updateremove, move, swap, replace
FormProvider / useFormContextNot needed (see notes)
unregister / shouldUnregisterNo equivalent (see notes)

A few entries deserve extra context:

  • FormProvider / useFormContext: The Formisch form store is a plain object. Pass it down as a prop, as our input components do, or share it through your own React context if you prefer.
  • unregister / shouldUnregister: In Formisch, the schema defines which fields exist, independent of what is currently mounted. For conditional form shapes, model the variants in the schema, for example with v.optional or v.variant.
  • watch callbacks and subscriptions: There is no subscription API because none is needed. Reading field.input or any form state during render is automatically reactive.
  • trigger('name'): The validate method always validates the entire form against the schema. The results are still distributed per field, so field-level triggering is not necessary.
  • shouldFocusError: Formisch focuses the first field with an error automatically when a submit fails, following the field order of your schema.
  • formState.isValid: In Formisch, form.isValid reflects the result of the last validation run. With the default validate: 'submit' timing it stays true until the first submit, unlike React Hook Form's isValid, which validates proactively. If you rely on it before the first submit, for example to disable the submit button, set validate: 'initial'.
  • formState.touchedFields: Formisch marks a field as touched when it receives focus, while React Hook Form marks it on blur. Logic that shows errors based on touched state therefore reacts one event earlier.

Common patterns

Form state

React Hook Form exposes form state through the formState Proxy, which requires you to destructure the properties you use so the subscription is set up correctly. In Formisch, you read state directly from the form store. Reads are tracked through signals, so components re-render automatically and only when the state they read changes.

// Before
const {
  formState: { isDirty, isSubmitting },
} = useForm<ProfileValues>();
<button type="submit" disabled={!isDirty || isSubmitting}>
  Save
</button>;

// After
const profileForm = useForm({ schema: ProfileSchema });
<button
  type="submit"
  disabled={!profileForm.isDirty || profileForm.isSubmitting}
>
  Save
</button>;

Validation timing

React Hook Form's mode and reValidateMode map to Formisch's validate and revalidate. The Formisch modes are named after DOM events: 'input' fires on every keystroke and is what React Hook Form calls onChange. A separate 'change' mode exists, but because React emits its change event on every keystroke for text inputs, both behave the same in React, so prefer 'input'. There are also more options like 'touch' and 'initial'.

// Before
const form = useForm<Values>({
  resolver: valibotResolver(Schema),
  mode: 'onTouched',
  reValidateMode: 'onChange',
});

// After: first validation on blur, revalidation on every keystroke
const form = useForm({
  schema: Schema,
  validate: 'blur',
  revalidate: 'input',
});

The defaults are already close: Formisch validates on submit first and revalidates on input, similar to React Hook Form's onSubmit mode with onChange revalidation. See the validation guide for all modes and for showing errors at the right time.

Field arrays

React Hook Form's useFieldArray returns fields with stable id keys plus mutation functions. Formisch provides the FieldArray component, whose items array contains unique string identifiers to use as keys, and standalone methods like insert and remove.

// Before
const { fields, append, remove } = useFieldArray({ control, name: 'todos' });

<>
  {fields.map((item, index) => (
    <div key={item.id}>
      <input {...register(`todos.${index}.label`)} />
      <button type="button" onClick={() => remove(index)}>
        Delete
      </button>
    </div>
  ))}
  <button type="button" onClick={() => append({ label: '' })}>
    Add
  </button>
</>;
// After
import { Field, FieldArray, insert, remove } from '@formisch/react';

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

Unlike append and prepend, the single insert method covers all positions through its optional at option. See the field arrays guide for moving, swapping, and validating arrays.

Reset

React Hook Form's reset() restores defaultValues, and reset(values) replaces them. The Formisch reset method works the same way with its config object, and it also resets individual fields through the path option, replacing resetField.

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

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

// Reset with new initial input, e.g. after refetching server data
reset(profileForm, { initialInput: { name: 'Jane' } });

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

As in React Hook Form, resetting updates the baseline for dirty tracking. Fields you plan to reset programmatically must be controlled, so bind value, checked, or selected as described in the controlled fields guide.

Special inputs

React Hook Form's register detects checkboxes, radio buttons, and selects through the input's ref. In Formisch, you spread field.props and bind the state attribute that matches the input type, for example checked for a checkbox:

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

Where you used register('age', { valueAsNumber: true }), note that Formisch reads DOM values as strings. Fields with a v.number() or v.date() schema convert the value in a controlled change handler instead, as shown in the controlled fields guide. For component libraries where you previously reached for Controller, pass field.input and field.onChange directly to the component. The special inputs guide covers checkboxes, radio buttons, selects, and file inputs in detail.

Next steps

You now have everything you need to migrate your forms. Start with the define your form and create your form guides to deepen the schema-first workflow, build reusable input components to keep your migrated forms tidy, and explore the form methods guide for everything that replaces React Hook Form's imperative API.

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