Migrate from TanStack Form

This guide walks you through migrating a form from TanStack Form (@tanstack/svelte-form) to Formisch. It covers the mental-model differences, a complete side-by-side example, a step-by-step migration path, and a mapping of TanStack Form APIs to their Formisch equivalents.

Migrating is a rewrite of the form layer, not a drop-in replacement. The two libraries structure forms differently: TanStack Form configures validation per field and reads state through subscriptions, while Formisch derives everything from a single Valibot schema. The form markup and your submission logic usually carry over with small changes. Both libraries can coexist in the same application, so you can migrate one form at a time and remove @tanstack/svelte-form once the last form is converted.

Key differences

TanStack Form infers its types from the defaultValues object you pass to createForm. Validation is attached separately: each field (or the form itself) receives a validators object keyed by trigger, such as onChange, onBlur, onSubmit or onMount, with async variants like onChangeAsync. A validator can be a function or a Standard Schema, so a Valibot schema can already appear in a TanStack Form codebase, but it is one validator among many rather than the definition of the form.

Formisch is schema-first. A single Valibot schema is the source of the form's TypeScript types, its runtime validation, and its structure, all at once. There is no defaultValues object to keep aligned with a separate type, and no per-field validator wiring. Validation always parses the entire form against the schema, so cross-field rules work without extra setup, and its timing is controlled form-wide by the validate and revalidate config.

The remaining differences you will notice during migration:

  • Field addressing: TanStack Form uses string names like "todos[0].label". Formisch uses type-safe path arrays like ['todos', 0, 'label'] that TypeScript checks against your schema.
  • Input binding: TanStack Form wires value, oninput and onblur by hand through field.handleChange and field.handleBlur. Formisch bundles the event handlers into field.props, which you spread onto the input element.
  • Reactivity: TanStack Form reads state through explicit subscriptions (form.Subscribe, form.useStore, now form.useSelector) with selector functions. Formisch state is fine-grained through Svelte 5 runes, so you read properties like form.isSubmitting directly and only the affected parts of the UI update.
  • Submission: TanStack Form calls the onSubmit option with the current values. Formisch validates against the schema first and calls your onsubmit handler only with typed, validated output.

Side-by-side example

The same login form with two validated fields and a submit handler, first in TanStack Form, then in Formisch.

TanStack Form

<script lang="ts">
  import { createForm } from '@tanstack/svelte-form';

  const form = createForm(() => ({
    defaultValues: {
      email: '',
      password: '',
    },
    onSubmit: async ({ value }) => {
      console.log(value);
    },
  }));
</script>

<form
  onsubmit={(event) => {
    event.preventDefault();
    event.stopPropagation();
    form.handleSubmit();
  }}
>
  <form.Field
    name="email"
    validators={{
      onBlur: ({ value }) =>
        !value
          ? 'Please enter your email.'
          : !/^\S+@\S+\.\S+$/.test(value)
            ? 'The email address is badly formatted.'
            : undefined,
    }}
  >
    {#snippet children(field)}
      <input
        type="email"
        name={field.name}
        value={field.state.value}
        onblur={field.handleBlur}
        oninput={(event) => field.handleChange(event.currentTarget.value)}
      />
      {#if field.state.meta.errors.length}
        <div>{field.state.meta.errors[0]}</div>
      {/if}
    {/snippet}
  </form.Field>

  <form.Field
    name="password"
    validators={{
      onBlur: ({ value }) =>
        !value
          ? 'Please enter your password.'
          : value.length < 8
            ? 'Your password must have 8 characters or more.'
            : undefined,
    }}
  >
    {#snippet children(field)}
      <input
        type="password"
        name={field.name}
        value={field.state.value}
        onblur={field.handleBlur}
        oninput={(event) => field.handleChange(event.currentTarget.value)}
      />
      {#if field.state.meta.errors.length}
        <div>{field.state.meta.errors[0]}</div>
      {/if}
    {/snippet}
  </form.Field>

  <form.Subscribe selector={(state) => state.isSubmitting}>
    {#snippet children(isSubmitting)}
      <button type="submit" disabled={isSubmitting}>Login</button>
    {/snippet}
  </form.Subscribe>
</form>

Formisch

<script lang="ts">
  import { createForm, Field, Form } from '@formisch/svelte';
  import type { SubmitHandler } from '@formisch/svelte';
  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.')
    ),
  });

  const loginForm = createForm({
    schema: LoginSchema,
    validate: 'blur',
  });

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

<Form of={loginForm} onsubmit={submitForm}>
  <Field of={loginForm} path={['email']}>
    {#snippet children(field)}
      <input {...field.props} value={field.input} type="email" />
      {#if field.errors}
        <div>{field.errors[0]}</div>
      {/if}
    {/snippet}
  </Field>

  <Field of={loginForm} path={['password']}>
    {#snippet children(field)}
      <input {...field.props} value={field.input} type="password" />
      {#if field.errors}
        <div>{field.errors[0]}</div>
      {/if}
    {/snippet}
  </Field>

  <button type="submit" disabled={loginForm.isSubmitting}>Login</button>
</Form>

The validation rules, error messages and types now live in one schema, the input wiring collapses into {...field.props}, and form state is read directly from the store without a subscription component.

Migration steps

Install Formisch

Add Formisch and Valibot to your project. Valibot is a peer dependency:

npm install @formisch/svelte valibot

Keep @tanstack/svelte-form installed until all forms are migrated. See the installation guide for other package managers.

Move validation into a Valibot schema

Collect the rules from your validators objects and express them as one Valibot schema. Each per-field validator becomes a validation action in the schema, together with its error message. The onBlur validator of the email field above becomes:

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

If your TanStack Form validators already use Valibot through Standard Schema, you can often merge the per-field schemas into a single object schema and reuse them as they are. 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. See the define your form guide for details.

Replace the form setup

TanStack Form's createForm takes a function returning options with defaultValues and onSubmit. The Formisch createForm rune takes a plain config object with your schema:

const loginForm = createForm({
  schema: LoginSchema,
  validate: 'blur',
});

You no longer need a defaultValues object just to establish types. Required string fields start as an empty string automatically. If you prefilled fields with defaultValues, pass those values as initialInput. The submission handler moves to the Form component in the next steps.

Replace field bindings

Replace form.Field with the Field component. The string name becomes a type-safe path array, the manual event wiring becomes a spread of field.props, and errors move from field.state.meta.errors to field.errors:

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

For field components built with TanStack Form's form composition (useFieldContext), the Formisch counterpart is the useField rune, which gives you the same field store inside your own components. See the add form fields and input components guides.

Update submission handling

Replace the native <form> element and the manual form.handleSubmit() call with the Form component. It renders a <form> element, calls event.preventDefault() for you, and only invokes your handler after the schema validation passes:

<script lang="ts">
  const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
    // `values` is the validated, fully typed schema output
    await loginUser(values);
  };
</script>

<Form of={loginForm} onsubmit={submitForm}>
  <!-- fields -->
</Form>

Unlike TanStack Form's onSubmit, which receives the current form values, the Formisch handler receives the parsed schema output. See the handle submission guide to learn more.

API mapping

TanStack FormFormisch
createForm(() => ({ … }))createForm({ schema })
defaultValuesinitialInput config
onSubmit optiononsubmit prop of Form
validators configValibot schema plus validate / revalidate config
<form.Field name="…">Field with path array
field.state.valuefield.input
field.handleChange / handleBlurSpread field.props, or field.onInput for custom inputs
field.state.meta.errorsfield.errors
field.state.meta.isTouchedfield.isTouched
field.state.meta.isDirtyfield.isEdited
form.Subscribe / form.useStore (now form.useSelector)Direct property access, e.g. form.isSubmitting
state.isSubmittingform.isSubmitting
state.canSubmitNo direct equivalent (see below)
form.handleSubmit()submit(form)
form.reset()reset(form)
form.setFieldValue(name, value)setInput(form, { path, input })
form.state.valuesgetInput(form)
form.validateAllFieldsvalidate(form)
<form.Field mode="array">FieldArray
field.pushValue / insertValueinsert(form, { path })
field.removeValueremove(form, { path, at })
field.moveValuemove(form, { path, from, to })
field.swapValuesswap(form, { path, at, and })
field.replaceValuereplace(form, { path, at, initialInput })

A few entries deserve a short note:

  • canSubmit: Formisch does not compute a "can submit" flag. The common pattern is to keep the submit button enabled: on submit, Formisch validates the whole form and automatically focuses the first field with an error. If you still want to reflect validity in the UI, form.isValid reflects the result of the last validation run, but with the default validate: 'submit' timing it stays true until the first submit, so combine it with validate: 'initial' for TanStack-like behavior.
  • Dirty state: TanStack Form's isDirty is persistent (it stays true after a change is reverted), which matches Formisch's isEdited. Formisch's isDirty compares the current input against the initial input, which matches TanStack Form's !isDefaultValue.
  • onMount validator: use validate: 'initial' in the Formisch config to validate as soon as the form is created.
  • Async validators: there is no counterpart to onChangeAsyncDebounceMs. Async checks live in the schema via v.pipeAsync and v.checkAsync, and the form-level form.isValidating reflects the in-flight state (Formisch does not track validating state per field, because validation always parses the whole schema).

Common patterns

Form state

TanStack Form reads reactive state through the form.Subscribe component or the form.useStore hook (now form.useSelector) with a selector, as shown in the side-by-side example above. In Formisch, the form store returned by the createForm rune is reactive through Svelte 5 runes, so you access properties directly and only the affected parts of the DOM update:

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

The form store exposes isSubmitting, isSubmitted, isValidating, isTouched, isEdited, isDirty, isValid and errors.

Validation timing

In TanStack Form, timing is part of each validator: the same rule behaves differently depending on whether you register it as onChange, onBlur or onSubmit. In Formisch, the rules live in the schema and the timing is configured once for the whole form:

const loginForm = createForm({
  schema: LoginSchema,
  validate: 'blur', // first validation on blur
  revalidate: 'input', // revalidate on every keystroke afterwards
});

validate controls when a field is first validated ('initial', 'touch', 'input', 'change', 'blur' or 'submit', defaulting to 'submit'), and revalidate controls subsequent runs once a field has an error or the form was submitted (defaulting to 'input'). To reveal errors only at the right moment, combine field.isEdited with form.isSubmitted as shown in the validation guide.

Field arrays

TanStack Form handles arrays with mode="array" and helper methods on the field:

<form.Field name="todos" mode="array">
  {#snippet children(todosField)}
    {#each todosField.state.value as _, index}
      <form.Field name={`todos[${index}].label`}>
        {#snippet children(field)}
          <input
            value={field.state.value}
            oninput={(event) => field.handleChange(event.currentTarget.value)}
          />
        {/snippet}
      </form.Field>
    {/each}
    <button type="button" onclick={() => todosField.pushValue({ label: '' })}>
      Add todo
    </button>
  {/snippet}
</form.Field>

Formisch provides the FieldArray component, whose items array contains stable IDs for keying the {#each} block, together with standalone methods like insert and remove:

<script lang="ts">
  import { Field, FieldArray, insert } from '@formisch/svelte';
</script>

<FieldArray of={todoForm} path={['todos']}>
  {#snippet children(fieldArray)}
    {#each fieldArray.items as item, index (item)}
      <Field of={todoForm} path={['todos', index, 'label']}>
        {#snippet children(field)}
          <input {...field.props} value={field.input} type="text" />
        {/snippet}
      </Field>
    {/each}
    <button
      type="button"
      onclick={() =>
        insert(todoForm, { path: ['todos'], initialInput: { label: '' } })}
    >
      Add todo
    </button>
  {/snippet}
</FieldArray>

Constraints such as a minimum or maximum number of items move into the schema, for example with v.nonEmpty() and v.maxLength(10) on the array. See the field arrays guide for the full picture.

Reset

TanStack Form resets with form.reset(), or form.reset(values) to also update the default values. Formisch uses the reset method:

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

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

// Reset and replace the initial input (the new dirty baseline)
reset(loginForm, { initialInput: { email: 'user@example.com' } });

Passing initialInput also updates the baseline for dirty tracking, just like passing values to TanStack Form's reset updates its default values.

Special inputs

In TanStack Form, every input type is wired manually by passing the parsed value to field.handleChange, for example toggling a boolean for a checkbox. In Formisch, you spread field.props and set the state attribute that matches the input type:

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

Checkboxes, radio buttons, selects and file inputs are covered in the special inputs guide. One thing to watch out for: Formisch's event handlers read values from the DOM as strings, so fields with a non-string schema like v.number() or v.date() must be controlled as described in the controlled fields guide. Custom components that don't expose a native element use field.onInput instead of field.props.

Next steps

With your first form migrated, work through the main concepts to solidify the Formisch mental model: define your form, create your form, add form fields and handle submission. The form methods guide lists everything that replaces TanStack Form's imperative API, and the validation guide covers error timing and async checks in depth.

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