Migrate from Modular Forms

This guide walks you through migrating a form from Modular Forms (@modular-forms/solid) to Formisch. Formisch is the official successor of Modular Forms, built by the same author. Modular Forms is in maintenance mode, and Formisch is the recommended library for new projects.

Because Formisch is a rewrite of the same ideas, much of your knowledge carries over. Both libraries are headless, both connect fields through a render prop, and most methods even kept their names. What changes is the source of truth: everything that Modular Forms spread across type parameters, validate props and type props now comes from a single Valibot schema.

Both libraries can coexist in the same application without conflicts. You can migrate one form at a time and remove Modular Forms once the last form is converted.

Key differences

Modular Forms derives its types from a manually declared type like LoginForm and wires validation separately: either per field via the validate prop with functions like required and email, or per form via a schema adapter like valiForm or zodForm. Fields are addressed by dot-notation name strings, and non-string fields declare their runtime type with a type prop.

Formisch is schema-first. A single Valibot schema provides everything at once: the TypeScript types, the runtime validation rules, and the structure of the form. There is no type parameter, no validation functions, no schema adapter, and no type prop. When the schema changes, every field path, every validation message and every inferred type follows automatically.

The main differences in practice:

  • Source of truth: Modular Forms combines a TypeScript type with separate validation wiring. Formisch derives types, validation and form structure from one Valibot schema.
  • Component access: Modular Forms returns pre-bound Form, Field and FieldArray components from createForm. In Formisch, you import them from the package and connect them with the of property.
  • Field paths: Modular Forms addresses fields with dot-notation strings like name="todos.0.label". Formisch uses type-safe path arrays like path={['todos', 0, 'label']}.
  • Field state: Modular Forms passes (field, props) to the render prop, with field.value and a single field.error string. Formisch passes one field store with field.input, an array of field.errors, and the element props at field.props.
  • Active state: Modular Forms tracks which fields are mounted and only validates and submits "active" fields. Formisch has no active-field concept, since the schema defines the structure of the form.
  • Methods: Most methods kept their names. They now take the form store and a single config object with a path array instead of a name string.

Side-by-side example

The following login form has two validated fields and an async submit handler, implemented once with Modular Forms and once with Formisch.

Modular Forms

import {
  createForm,
  email,
  minLength,
  required,
  type SubmitHandler,
} from '@modular-forms/solid';

type LoginForm = {
  email: string;
  password: string;
};

export default function LoginPage() {
  const [loginForm, { Form, Field }] = createForm<LoginForm>();

  const submitForm: SubmitHandler<LoginForm> = async (values) => {
    await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
  };

  return (
    <Form onSubmit={submitForm}>
      <Field
        name="email"
        validate={[
          required('Please enter your email.'),
          email('The email address is badly formatted.'),
        ]}
      >
        {(field, props) => (
          <div>
            <input {...props} value={field.value || ''} type="email" />
            {field.error && <div>{field.error}</div>}
          </div>
        )}
      </Field>
      <Field
        name="password"
        validate={[
          required('Please enter your password.'),
          minLength(8, 'Your password must have 8 characters or more.'),
        ]}
      >
        {(field, props) => (
          <div>
            <input {...props} value={field.value || ''} type="password" />
            {field.error && <div>{field.error}</div>}
          </div>
        )}
      </Field>
      <button type="submit" disabled={loginForm.submitting}>
        Login
      </button>
    </Form>
  );
}

Formisch

import { createForm, Field, Form } from '@formisch/solid';
import type { SubmitHandler } from '@formisch/solid';
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 = createForm({
    schema: LoginSchema,
  });

  const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
    await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(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>
  );
}

Note that the manual LoginForm type and the per-field validate arrays are gone. The schema provides both, and the types flow from it into path, field.input and the values of your submit handler.

Migration steps

Install Formisch

Formisch requires Valibot as a peer dependency. Install both packages alongside Modular Forms, so you can migrate form by form:

npm install @formisch/solid valibot

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

Move validation into the schema

How this step looks depends on how you validated with Modular Forms:

  • With valiForm: You already have a Valibot schema. Pass it directly to createForm as the schema config and delete the adapter.
  • With zodForm: Formisch validates exclusively with Valibot, so translate your Zod schema. The structure maps one to one, and most rules have a same-named Valibot action.
  • With field-level validate props: Each validation function becomes a Valibot action in the schema, keeping its error message.

The validation functions map as follows:

Modular FormsValibot
requiredv.nonEmpty for strings/arrays
emailv.email
urlv.url
minLength / maxLengthv.minLength / v.maxLength
minRange / maxRangev.minValue / v.maxValue
patternv.regex
valuev.value
minSize / maxSizev.minSize / v.maxSize
minTotalSize / maxTotalSizev.check with a custom function
mimeTypev.mimeType
customv.check / v.checkAsync

One semantic difference to keep in mind: in Modular Forms, every validation function except required skips empty strings and lists. Valibot actions do not skip those values automatically, and v.optional only accepts undefined. To preserve an optional text field that accepts an empty input, explicitly allow it, for example with v.union([v.literal(''), v.pipe(v.string(), v.email())]); wrap that schema in v.optional only if undefined is also a valid input. A required string becomes v.pipe(v.string(), v.nonEmpty('...')); use a type-appropriate schema for other inputs.

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

Modular Forms returns a tuple of the form store and pre-bound components. Formisch returns only the store, and you import Form, Field and FieldArray from the package, connecting them with the of property. The initialValues option becomes initialInput, and there is no type parameter because createForm infers all types from the schema:

// Modular Forms
const [loginForm, { Form, Field }] = createForm<LoginForm>({
  initialValues: { email: '', password: '' },
  validate: valiForm(LoginSchema),
});

// Formisch
const loginForm = createForm({
  schema: LoginSchema,
});

The validateOn and revalidateOn options become validate and revalidate. Their existing modes carry over with one rename: 'touched' becomes 'touch'; Formisch additionally supports 'initial' and 'change'. The defaults are unchanged, so a form that validates on submit and revalidates on input needs no config at all.

Replace field bindings

Three things change on each field: the name string becomes a path array, the second props render argument moves onto the field store as field.props, and the validate and type props are dropped because the schema covers both:

// Modular Forms
<Field name="email" validate={[required('Please enter your email.')]}>
  {(field, props) => (
    <div>
      <input {...props} value={field.value || ''} type="email" />
      {field.error && <div>{field.error}</div>}
    </div>
  )}
</Field>

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

Dot-notation names translate directly: name="account.email" becomes path={['account', 'email']}, and a dynamic name like 'todos.' + index() + '.label' becomes path={['todos', index(), 'label']}. Instead of a single field.error string that is empty when valid, Formisch provides field.errors, which is an array of all error messages or null when the field is valid, so field.errors[0] gives you the Modular Forms behavior.

Also note that fields with a schema like v.number() or v.date() must be controlled, since their values are read from the DOM as strings and Formisch has no type prop that converts them. Checkboxes, radio groups, selects and file inputs are decoded automatically based on the element and the schema. See the add form fields and controlled fields guides for details.

Update submission handling

The onSubmit handler on the Form component works the same way: it runs after successful validation and receives the typed values. The SubmitHandler type now takes the schema instead of a values type:

// Modular Forms
const submitForm: SubmitHandler<LoginForm> = async (values) => {
  /* ... */
};

// Formisch
const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
  /* ... */
};

What Modular Forms handled through FormError and the response store becomes regular control flow. Instead of throwing a FormError with field errors, call setErrors for errors the server attributes to a field, and track a status message in a Solid signal instead of reading form.response:

import { setErrors } from '@formisch/solid';
import { createSignal } from 'solid-js';

const [message, setMessage] = createSignal('');

const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
  const response = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(values),
  });
  if (response.status === 401) {
    setErrors(loginForm, {
      path: ['password'],
      errors: ['The password is incorrect.'],
    });
  } else if (!response.ok) {
    setMessage('An unknown error has occurred.');
  }
};

See the handle submission guide for details.

API mapping

Modular Forms (@modular-forms/solid)Formisch (@formisch/solid)
createForm<LoginForm>(options)createForm with schema config
[form, { Form, Field, FieldArray }] tupleForm store + imported components with of property
initialValuesinitialInput config option
validate: valiForm(Schema)schema config option
validate: zodForm(Schema)schema config option (translated to Valibot)
validateOn / revalidateOnvalidate / revalidate config ('touched' is now 'touch')
Per-field validateOn / revalidateOnNo equivalent (validation modes are form-wide)
<Field name="a.b">Field with path={['a', 'b']}
validate prop with validation functionsValibot schema actions
type prop ('number', 'boolean', ...)Inferred from schema and element (number and date fields are controlled)
transform prop, toTrimmed, toCustom, ...Valibot transformation actions like v.trim (see below)
keepActive / keepState propsNo equivalent (field state is kept automatically)
(field, props) render argumentsField store with field.props
field.valuefield.input
field.error (string)field.errors (array or null)
field.touched / field.dirtyfield.isTouched / field.isDirty
field.activeNo equivalent
form.submitting / form.submittedform.isSubmitting / form.isSubmitted
form.validatingform.isValidating
form.invalidform.isValid (inverted)
form.touched / form.dirtyform.isTouched / form.isDirty
form.submitCountNo equivalent
form.response / setResponse / clearResponseYour own signal + control flow in the submit handler
FormErrorsetErrors + control flow
shouldActive / shouldTouched / shouldDirty on FormNo equivalent (all schema fields are validated and submitted)
getValue(form, name) / getValues(form)getInput
setValue(form, name, value) / setValuessetInput
getError(form, name)getErrors with path
getErrors(form)getDeepErrors
setError(form, name, error)setErrors
clearError(form, name)setErrors with errors: null
hasField / hasFieldArrayNot needed (the schema defines the structure)
validate(form, name?)validate (always validates the entire form)
focus(form, name)focus with path
reset(form, name?, options)reset with path / initialInput config
insert(form, name, { at, value })insert with path / at / initialInput
remove(form, name, { at })remove
replace(form, name, { at, value })replace
move(form, name, { from, to })move
swap(form, name, { at, and })swap
submit(form)submit
FormValues<typeof form>v.InferInput<typeof Schema>

The touched flags are close but not identical: Modular Forms marks a field as touched after blur or a dirty input, while Formisch marks it on focus or whenever its input is set. The dirty flags in both libraries compare the current and initial input.

Common patterns

Form state

Both libraries expose form state as reactive properties on the store, so this part barely changes. The properties follow an is naming convention, and invalid flips to isValid:

// Modular Forms
loginForm.submitting; // boolean
loginForm.invalid; // boolean

// Formisch
loginForm.isSubmitting; // boolean
loginForm.isValid; // boolean

Values are read with the getInput method, which is reactive like getValue and getValues in Modular Forms:

import { getInput } from '@formisch/solid';

getInput(loginForm, { path: ['email'] }); // string | undefined
getInput(loginForm); // partial form values

Conditional fields and active state

Modular Forms only validates and submits fields that are "active", meaning currently mounted. This is how conditional forms work there: render a <Field> only when it applies, and its value disappears from the output. The keepActive and keepState props and the shouldActive options fine-tune this behavior.

Formisch has no active-field concept. Every field in the schema is validated on every submit, and every value is part of the output, whether a Field component is currently rendered or not. Model conditional structures in the schema instead, for example with v.variant for a discriminated union:

const PaymentSchema = v.variant('type', [
  v.object({
    type: v.literal('card'),
    number: v.pipe(v.string(), v.nonEmpty('Please enter your card number.')),
  }),
  v.object({
    type: v.literal('paypal'),
    email: v.pipe(v.string(), v.email('The email address is badly formatted.')),
  }),
]);

This way, the schema only requires the fields of the selected variant, which replaces the mount-based logic of Modular Forms with type-safe validation logic.

Validation timing

The validateOn and revalidateOn options carry over as validate and revalidate with the same defaults ('submit' and 'input'). The 'touched' mode is now called 'touch' and follows Formisch's touched behavior described above. Formisch also accepts 'initial' to validate as soon as the form is created and 'change' for the native change event:

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

Unlike Modular Forms, validation modes cannot be overridden per field, and validation always runs against the entire schema rather than a single field. Formisch only updates the DOM where errors actually change, so this has no rendering cost. It does mean, however, that expensive or asynchronous rules like v.checkAsync run on every validation pass, not only when their own field changes. The validation guide covers the details.

Field arrays

The mental model is unchanged: fieldArray.items contains stable keys for SolidJS's <For> component, and array methods modify the items. What changes is that the array structure and its validation move into the schema, and item values are passed as initialInput:

import { createForm, Field, FieldArray, insert, remove } from '@formisch/solid';
import { For } from 'solid-js';

const TodoSchema = v.object({
  todos: v.pipe(
    v.array(
      v.object({
        label: v.pipe(v.string(), v.nonEmpty('Please enter a label.')),
      })
    ),
    v.nonEmpty('Please add at least one todo.'),
    v.maxLength(4, 'You cannot add more than 4 todos.')
  ),
});

const todoForm = createForm({ schema: TodoSchema });

<FieldArray of={todoForm} path={['todos']}>
  {(fieldArray) => (
    <For each={fieldArray.items}>
      {(_, getIndex) => (
        <div>
          <Field of={todoForm} path={['todos', getIndex(), 'label']}>
            {(field) => (
              <input {...field.props} value={field.input} type="text" />
            )}
          </Field>
          <button
            type="button"
            onClick={() =>
              remove(todoForm, { path: ['todos'], at: getIndex() })
            }
          >
            Remove
          </button>
        </div>
      )}
    </For>
  )}
</FieldArray>;

<button
  type="button"
  onClick={() =>
    insert(todoForm, { path: ['todos'], initialInput: { label: '' } })
  }
>
  Add todo
</button>;

The validate prop of Modular Forms' FieldArray, which checked the number of items with functions like required and maxLength, becomes v.nonEmpty and v.maxLength on the v.array pipe. See the field arrays guide for a complete example.

Special inputs

Modular Forms decodes checkboxes, number inputs, file inputs and dates through the type prop on Field. Formisch decodes checkboxes, radio groups, selects and file inputs automatically based on the element and the schema, so no type prop is needed. Setting the appropriate controlled attribute, like checked for a boolean checkbox, keeps the DOM in sync with initial values and programmatic updates:

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

Number and date inputs are the exception: their values are read from the DOM as strings, so they must be controlled to store the correct type. File inputs are the opposite case: they cannot be controlled at all and are connected through field.props alone. The special inputs guide shows the equivalent patterns for checkbox groups, radio groups, selects, file inputs and dates.

Transformations

Modular Forms transforms input values as the user types via the transform prop with helpers like toTrimmed and toUpperCase. In Formisch, transformations live in the schema as Valibot actions like v.trim and v.toUpperCase:

const Schema = v.object({
  email: v.pipe(v.string(), v.trim(), v.email()),
});

Note the semantic difference: schema transformations apply to the validated output your submit handler receives, not to the value displayed in the input while typing. If you need to constrain what the user sees as they type, for example for an input mask, use a controlled field and set the transformed value with setInput.

Next steps

With your first form migrated, work through the define your form, add form fields and handle submission guides to deepen the concepts this guide touched on. The controlled fields and form methods guides are useful next reads once your migrated forms grow more dynamic.

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