A glowing blueprint unfurling into form fields on a developer's desk, with a large red Maine Coon cat resting nearby.

Formisch: One Schema to Rule Your Forms

The Orange Cat
The Orange Cat

If you have ever wired up a form in React, you know the dance: you define a state shape, you define a validation schema somewhere else, you bolt them together with a resolver, and then you spend the afternoon convincing TypeScript that field.value is actually a string. Formisch wants to retire that dance. It is a schema-based, headless, fully type-safe form library where you define a single Valibot schema and the entire form, its state, its validation, and all of its types flow directly out of it.

Formisch comes from Fabian Hiller, the author of Valibot and the earlier Modular Forms library, so the schema-first philosophy is baked in rather than retrofitted. It is also genuinely framework-agnostic: the same mental model ships for React, Solid, Vue, Svelte, Preact, and Qwik, each compiled down to that framework's native reactivity. In this article we will focus on the React package, @formisch/react, which is powered by the shared @formisch/core engine.

Why Formisch Feels Different

Most form libraries make you pick a side. Controlled, component-heavy libraries re-render constantly. Uncontrolled, performance-first libraries are fast but treat validation and typing as an add-on you wire in through resolvers. Formisch sidesteps the tradeoff by starting from the schema and never letting go of it. Here is what that buys you:

  • End-to-end type safety. Field paths, values, and errors are all inferred from your Valibot schema. Even deep paths like ['todos', index, 'label'] get full editor autocompletion.
  • Truly headless. Formisch ships no markup and no styles. You render native HTML inputs and decide exactly how everything looks.
  • Fine-grained reactivity. Only the fields that actually change re-render, so large forms stay snappy.
  • A modular, tree-shakeable API. You import only the manipulation methods you use, which is why bundles can start around 2.5 kB.
  • First-class field arrays. Dynamic lists come with insert, remove, move, swap, and replace out of the box.
  • One mental model everywhere. The same primitives work across six frameworks, which is a gift if your team ships more than one stack.

One honest caveat up front: Formisch is young. Every package is still in the 0.x range and breaking changes can land between minor versions. Valibot is also a hard requirement rather than an option, and the typed-path approach really only shines in TypeScript. If you need a battle-tested incumbent today, keep that in mind. If you like the architecture and are comfortable on the frontier, read on.

Getting It Into Your Project

Formisch leans on TypeScript 5+ and uses Valibot as a peer dependency, so you install both alongside the framework package.

With npm:

npm install @formisch/react valibot

With yarn:

yarn add @formisch/react valibot

You do not install @formisch/core yourself; it is the shared engine that @formisch/react pulls in automatically. Make sure you are on TypeScript 5 or newer to get the full type-inference experience.

Your First Schema-Driven Form

Let's build a login form. The defining move with Formisch is that the schema comes first, and everything else hangs off it. We define the shape and validation once with Valibot, then hand it to useForm.

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

const LoginSchema = v.object({
  email: v.pipe(v.string(), v.email()),
  password: v.pipe(v.string(), v.minLength(8)),
});

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

  return (
    <Form of={loginForm} onSubmit={(output) => console.log(output)}>
      <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">Login</button>
    </Form>
  );
}

There are a few conventions worth slowing down on. useForm({ schema }) initializes the form store and infers the input and output types straight from LoginSchema. The <Form> component is a thin wrapper over a native <form> that handles validation and submission. When the form is valid and submitted, onSubmit receives output, which is the fully validated, fully typed schema output, so output.email is a known string with no casting required.

Each <Field> uses a render prop. The field.props object spreads the native event and ref bindings onto your input, field.input is the current value, and field.errors is an array of error messages for that field. Notice the path={['email']} prop: fields are addressed by path arrays rather than dot-string paths, which is a little more verbose but lets TypeScript verify that the path actually exists in your schema.

Reaching Into Nested Shapes

Real forms are rarely flat. Because Formisch addresses fields by path arrays, nesting is just a longer path. Suppose your schema describes a user with an address:

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

const ProfileSchema = v.object({
  user: v.object({
    name: v.pipe(v.string(), v.minLength(1)),
    address: v.object({
      city: v.pipe(v.string(), v.minLength(1)),
      zip: v.pipe(v.string(), v.regex(/^\d{5}$/)),
    }),
  }),
});

export default function ProfileForm() {
  const profileForm = useForm({ schema: ProfileSchema });

  return (
    <Form of={profileForm} onSubmit={(output) => console.log(output)}>
      <Field of={profileForm} path={['user', 'address', 'city']}>
        {(field) => (
          <input {...field.props} value={field.input} placeholder="City" />
        )}
      </Field>
      <Field of={profileForm} path={['user', 'address', 'zip']}>
        {(field) => (
          <input {...field.props} value={field.input} placeholder="ZIP" />
        )}
      </Field>
      <button type="submit">Save</button>
    </Form>
  );
}

The path ['user', 'address', 'city'] is checked against the schema at compile time. Mistype a segment and TypeScript complains before you ever run the code. This is the quiet payoff of the path-array design: it trades a touch of verbosity for the kind of autocompletion and safety that string paths can never fully deliver.

Building Dynamic Lists With Field Arrays

Dynamic, repeatable groups of fields are where form libraries earn their keep. Formisch handles them with a <FieldArray> component plus a set of standalone manipulation methods. Here is a todo list where each item has a label.

import {
  FieldArray,
  Field,
  Form,
  useForm,
  insert,
  remove,
} from '@formisch/react';
import * as v from 'valibot';

const TodosSchema = v.object({
  todos: v.array(
    v.object({
      label: v.pipe(v.string(), v.minLength(1)),
      deadline: v.string(),
    })
  ),
});

export default function TodoForm() {
  const todoForm = useForm({ schema: TodosSchema });

  return (
    <Form of={todoForm} onSubmit={(output) => console.log(output)}>
      <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 })}
                >
                  Remove
                </button>
              </div>
            ))}
            <button
              type="button"
              onClick={() =>
                insert(todoForm, {
                  path: ['todos'],
                  initialInput: { label: '', deadline: '' },
                })
              }
            >
              Add todo
            </button>
          </div>
        )}
      </FieldArray>
      <button type="submit">Save</button>
    </Form>
  );
}

The thing to notice is the method signature. Instead of fieldArray.append(...), you call standalone functions that take the form store as their first argument: insert(todoForm, { ... }). This is what keeps the API tree-shakeable, since the bundler only includes the methods you actually import. The full toolkit reads naturally once you internalize the pattern:

insert(form,  { path: ['todos'], initialInput: { label: '', deadline: '' }, at: 0 });
remove(form,  { path: ['todos'], at: 2 });
move(form,    { path: ['todos'], from: 0, to: 3 });
swap(form,    { path: ['todos'], at: 0, and: 1 });
replace(form, { path: ['todos'], at: 0, initialInput: { label: 'New', deadline: '' } });

The at option on insert is optional; omit it and the new item lands at the end. Using item as the React key rather than the index keeps reordering stable, which matters once you start moving and swapping rows around.

Controlling When Validation Fires

By default Formisch validates on blur and again on submit, which strikes a good balance between helpfulness and nagging. When you want different behavior, you configure it in the useForm call. The validate option controls when initial validation runs and revalidate controls when a field re-validates after it has already shown an error.

const signUpForm = useForm({
  schema: SignUpSchema,
  validate: 'input',
  revalidate: 'input',
});

Switching to 'input' validates as the user types, which is often what you want once a field has earned an error and you want it to clear the moment it becomes valid again. Beyond validation timing, Formisch exposes a rich set of programmatic methods that all follow the same method(form, options) shape, including focus, getInput, setInput, getErrors, getAllErrors, setErrors, reset, validate, and submit. Need to focus the first invalid field after a failed submit, prefill values from a server response, or imperatively reset the form? Reach for these and you stay entirely within the typed world your schema defines.

import { setInput, reset, focus } from '@formisch/react';

setInput(signUpForm, { path: ['user', 'name'], input: 'Ada' });
focus(signUpForm, { path: ['user', 'name'] });
reset(signUpForm);

Each call is checked against the schema, so a typo in a path or a value of the wrong type surfaces at compile time rather than as a confusing runtime bug.

The Architecture Underneath

It is worth understanding why Formisch can claim native performance across six frameworks without shipping a lowest-common-denominator abstraction. The form-state engine lives in the shared @formisch/core package, and the manipulation methods live in @formisch/methods. At build time, framework-specific reactivity blocks are inserted into the core so that the React package uses React's state primitives, the Solid package uses signals, the Svelte package uses runes, and so on. The author describes the vision as "Vite, but for forms": one shared core and one mental model, compiled to run natively everywhere. That is also why the bundle stays small and the updates stay fine-grained, since each framework gets exactly its own idiomatic reactivity rather than a generic shim.

Should You Reach For It?

Formisch is a sharp tool for a specific shape of problem. If you already use Valibot or want to, and you like the idea of your form types, validation, and shape all flowing from a single source of truth, it removes an entire category of glue code. If your team ships across multiple frameworks, the consistent mental model is a real productivity win. And if you care about bundle size and fine-grained performance while keeping full control of your markup, the headless, tree-shakeable design fits beautifully.

Temper that with the realities: it is pre-1.0 with churn between minor versions, Valibot is mandatory rather than optional, and the value proposition leans hard on TypeScript. For a greenfield project where you can adopt Valibot from the start and you appreciate the schema-first philosophy, Formisch is a delightful, forward-looking choice. Define your schema, hand it to useForm, and let one source of truth carry your form from input to validated output.