Migrate from Formik

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

Migrating is a rewrite of the form layer, not a drop-in replacement. The mental model shifts: instead of starting from an initialValues object and attaching validation to it, you start from a Valibot schema that provides values, types, and validation at once. 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 Formik, migrate a single form, and remove Formik once the last one is converted. Since both libraries export Field, Form, FieldArray, and useField, the import path determines which ones a component uses.

Key differences

Formik is values-first. You declare an initialValues object, TypeScript infers your form's types from it, and validation is attached separately through a validationSchema written with Yup, a validate function, or validate props on individual fields. All form state lives in a single React state object and reaches your inputs through render props or context.

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 initialValues object to keep aligned with your validation, and state is read directly from a signal-based form store.

The most important differences at a glance:

  • Type source: Formik infers types from initialValues, and your Yup schema is typed separately. Formisch infers all types, including field paths and the validated output, from the schema.
  • Validation location: Formik spreads validation across validationSchema, the validate function, and field-level validate props. Formisch defines all validation in the schema, next to the structure it validates.
  • Validation timing: Formik has two boolean switches, validateOnChange and validateOnBlur, both true by default, so validation runs on every keystroke from the start. Formisch uses the validate and revalidate options with event-based modes and by default stays quiet until the first submit.
  • Error display: Formik computes errors eagerly and expects you to gate their display with the touched object, which is what ErrorMessage does internally. In Formisch, errors appear on a field only once validation has run, so no gating is needed in the default configuration.
  • Reactivity: Formik stores all form state in React state, so every keystroke re-renders the entire <Formik> subtree, which is why <FastField> exists. Formisch is built on fine-grained signals, so each field only re-renders when its own state changes.
  • Field binding: Formik connects inputs by string name (<Field name="user.email" />). Formisch connects fields through the headless Field component with a type-safe path array (path={['user', 'email']}).

If your forms already use validationSchema, the structure of your Yup schemas translates almost one-to-one to Valibot.

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.

Formik

import { ErrorMessage, Field, Form, Formik } from 'formik';
import * as Yup from 'yup';

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

export default function LoginPage() {
  return (
    <Formik
      initialValues={{ email: '', password: '' }}
      validationSchema={LoginSchema}
      onSubmit={async (values) => {
        // Process the validated form values
        console.log(values);
      }}
    >
      {({ isSubmitting }) => (
        <Form>
          <Field name="email" type="email" />
          <ErrorMessage name="email" component="div" />
          <Field name="password" type="password" />
          <ErrorMessage name="password" component="div" />
          <button type="submit" disabled={isSubmitting}>
            Login
          </button>
        </Form>
      )}
    </Formik>
  );
}

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 Yup schema became a Valibot schema that now also provides the form's types, and how initialValues disappeared because required string fields start as an empty string automatically. The onSubmit handler moved from the Formik component to the Form component and receives the validated output of the schema. And instead of the ErrorMessage component, each field renders its field.errors directly. One timing difference: Formik validates on every keystroke from the start and hides the messages behind its touched gate, while Formisch by default validates on the first submit and then revalidates on every input. The validation timing section below shows how to align the two.

Migration steps

Install Formisch

Add Formisch and Valibot alongside Formik. 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 Yup schema, validate function, or field-level validate props into a single Valibot schema. Most Yup rules have a direct counterpart: .required() becomes v.nonEmpty(), .min() becomes v.minLength(), .email() becomes v.email(), and .matches() becomes 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.')
  ),
});

Cross-field rules that you built with Yup.ref, such as a password confirmation, become schema-level checks with v.forward and v.partialCheck. Define the fields in the same order they appear in your form, since Formisch focuses the first invalid field on submit based on the schema order. The define your form guide covers schema design in detail.

Replace the form setup

Whether your forms use the useFormik hook, the <Formik> component, or the withFormik higher-order component, they all become the same useForm hook. There is no render-prop or HOC variant because you read state directly from the returned form store.

// Before
import { useFormik } from 'formik';

const formik = useFormik({
  initialValues: { email: '', password: '' },
  validationSchema: LoginSchema,
  onSubmit: async (values) => {
    console.log(values);
  },
});
// After
import { useForm } from '@formisch/react';

const loginForm = useForm({ schema: LoginSchema });

initialValues is replaced by the optional initialInput: required string fields start as an empty string automatically, so you only pass it when you have real data, such as a profile loaded from the server. The onSubmit option moves to the Form component (see below), and the validateOnChange and validateOnBlur switches map to the validate and revalidate options. See the create your form guide for all configuration options.

Replace field bindings

Replace each Formik Field with the Formisch Field component. The Formisch version is headless: it never renders an input itself, so the as, component, and children variants of Formik's Field all become the same pattern, a render function that spreads field.props onto your input and binds field.input as its value. Where you wired inputs manually with getFieldProps('email') or handleChange, handleBlur, and values.email, the spread replaces all of them at once.

// Before
<>
  <Field name="email" type="email" />
  <ErrorMessage name="email" component="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 names become type-safe path arrays: name="user.email" turns into path={['user', 'email']} and name={`todos.${index}.label`} turns into path={['todos', index, 'label']}. The ErrorMessage component disappears: field.errors is either null or a non-empty array of strings, so you render it right next to the input. Custom inputs built on Formik's useField map to the Formisch useField hook, which returns a single field store instead of a [field, meta, helpers] tuple: meta.error becomes field.errors, meta.touched becomes field.isTouched, and helpers.setValue becomes field.onChange. The add form fields guide explains both in detail.

Update submission handling

Replace the <form> element wired to formik.handleSubmit (or Formik's own Form component) with the Formisch Form component, and move the handler from the form config to its onSubmit prop. The handler only runs after validation succeeds and receives the validated values, typed as the schema's output.

// Before
<form onSubmit={formik.handleSubmit}>{/* fields */}</form>

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

setSubmitting disappears. Formik resets isSubmitting automatically only when your handler returns a promise and expects a manual setSubmitting(false) otherwise, while Formisch manages loginForm.isSubmitting in both cases. The same applies to the rest of Formik's submission bag: helpers like resetForm and setFieldError that Formik passes as the second argument are standalone methods in Formisch, such as reset and setErrors, which you call with the form store from anywhere. See the handle submission guide for details.

API mapping

FormikFormisch
useFormik / <Formik> / withFormikuseForm with schema (types are inferred)
initialValuesinitialInput option (optional)
validationSchema / validateschema option
validate prop of FieldValidation rules in the schema
validateOnChange / validateOnBlurvalidate / revalidate options (see notes)
<Form>Form component with of and onSubmit props
<Field name="email" />Field component with path={['email']}
getFieldProps('email')Spread field.props and bind value={field.input}
<ErrorMessage name="email" />field.errors rendered next to the input (see notes)
useField('email')useField hook with path: ['email']
valuesfield.input, getInput
errorsfield.errors per field, getErrors, getDeepErrors
touchedfield.isTouched (see notes)
dirtyform.isDirty (see notes)
isValidform.isValid (see notes)
isSubmittingform.isSubmitting
isValidatingform.isValidating
submitCountNo direct equivalent (see notes)
handleSubmit / submitFormonSubmit prop of Form, submit
handleReset / resetFormreset
enableReinitializereset with initialInput (see notes)
setFieldValue / setValuessetInput
setFieldError / setErrorssetErrors
setFieldTouched / setTouchedNo direct equivalent (see notes)
validateField / validateFormvalidate (see notes)
<FieldArray name="todos">FieldArray component or useFieldArray hook
push / unshift / insertinsert
remove / popremove
swap / move / replaceswap, move, replace
<FastField>Not needed (see notes)
useFormikContext / connectNot needed (see notes)
status / setStatusNo equivalent (see notes)

A few entries deserve extra context:

  • ErrorMessage and the touched gate: Formik validates eagerly and computes errors for every field, so their display must be gated behind touched, which ErrorMessage handles internally. Formisch populates field.errors only once validation has actually run for a field. With the default validate: 'submit' timing there is nothing to gate, and if you validate earlier, guard the display with field state like isEdited, as shown in the validation guide.
  • touched: Formisch marks a field as touched when it receives focus, while Formik marks it on blur. Logic that shows errors based on touched state therefore reacts one event earlier.
  • dirty: Formik's dirty is comparison-based: it is true while values differs from initialValues and flips back off when the user reverts their changes. That matches form.isDirty exactly. Formisch's form.isEdited is the sticky variant that stays true once anything was edited, even after the value is changed back.
  • 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 Formik's isValid, which is recomputed on every change. If you rely on it before the first submit, for example to disable the submit button, set validate: 'initial'.
  • submitCount: Formisch tracks whether the form has been submitted as the boolean form.isSubmitted instead of a counter. If you used submitCount > 0 to reveal errors after the first submit attempt, form.isSubmitted covers that. An actual attempt count requires your own state.
  • validateField: 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.
  • setFieldTouched / setTouched: There is no imperative touched setter. Fields become touched automatically when they receive focus, and the custom blur handlers these helpers are typically used in are replaced by spreading field.props.
  • <FastField>: FastField exists to cut down Formik's subtree re-renders. Formisch's signal-based store already re-renders each field only when its own state changes, so an opt-out component is not needed.
  • useFormikContext / connect: 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.
  • status / setStatus: Form-adjacent state like a submission error message is regular component state in Formisch. For errors that should live on the form itself, setErrors sets root-level errors that appear in form.errors.

Common patterns

Form state

Formik hands you form state through the object returned by useFormik or through render props, and updating any part of it re-renders everything below the form. 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 formik = useFormik({ initialValues, onSubmit });
<button type="submit" disabled={!formik.dirty || formik.isSubmitting}>
  Save
</button>;

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

Formik's timing options are boolean switches: validateOnChange and validateOnBlur turn whole trigger types on or off, and validateOnMount validates once on mount. Formisch instead distinguishes between the first validation of a field and every following one:

// Closest match to Formik's default behavior
const loginForm = useForm({
  schema: LoginSchema,
  validate: '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 once it has an error or the form was submitted. With validate: 'input', each field is validated from the first keystroke, like Formik's default. In practice, the behavior your users saw is closer to validate: 'blur' with revalidate: 'input', because Formik's touched gate delays the first visible error until the field is left. If you disabled both switches to validate only on submit, use validate: 'submit' with revalidate: 'submit' for the same behavior. The Formisch defaults (validate: 'submit', revalidate: 'input') also stay quiet until the first submit, but afterwards they re-check fields on every keystroke instead of leaving stale errors until the next submit. Finally, validateOnMount maps to validate: 'initial'. The validation guide covers all modes and how to show errors at the right time.

Field arrays

Formik's FieldArray provides its mutation helpers through a render prop, you map over the corresponding value from values, and the docs key items by index:

// Before, inside the <Formik> render prop
<FieldArray name="todos">
  {({ push, remove }) => (
    <div>
      {values.todos.map((_, index) => (
        <div key={index}>
          <Field name={`todos.${index}.label`} />
          <button type="button" onClick={() => remove(index)}>
            Delete
          </button>
        </div>
      ))}
      <button type="button" onClick={() => push({ label: '' })}>
        Add
      </button>
    </div>
  )}
</FieldArray>

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:

// 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>;

The helpers map as follows: push becomes insert, which appends by default, unshift becomes insert with at: 0, and insert(index, value) becomes insert with the at option. remove(index) and pop become remove with the at option, while swap, move, and replace map one-to-one. Unlike Formik's helpers, the Formisch methods are not bound to a render prop: they work anywhere you have access to the form store. 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

Formik's resetForm() restores initialValues, and resetForm({ values }) replaces them. The Formisch reset method covers both through its config object and additionally resets individual fields through the path option.

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

reset with initialInput also replaces enableReinitialize: instead of a flag that watches initialValues with deep equality, you call reset with the fresh data when it arrives, for example in an effect after a refetch. As in Formik, 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

Formik switches its binding behavior based on the type you pass to Field or useField: type="checkbox" binds checked, and radio buttons compare against value. In Formisch, you spread field.props and bind the state attribute that matches the input type yourself:

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

The special inputs guide covers checkboxes, radio buttons, selects, and file inputs in detail. One caveat carried over from the DOM: event handlers read strings, so where you parsed event.target.value inside a setFieldValue call, fields with a v.number() or v.date() schema need to be controlled instead, as explained in the controlled fields guide.

Next steps

You now have everything you need to migrate your forms one at a time. Start with the define your form and create your form guides to deepen the schema-first workflow, build reusable input components to replace your Formik field components, and explore the form methods guide for everything that replaces Formik's imperative helpers.

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