A blueprint of a form layout on a desk with a calm gray-blue cat resting nearby

Formisch: One Schema to Rule Your Forms

The Gray Cat
The Gray Cat
0 views

Forms are where most front-end codebases quietly accumulate complexity. You start with a couple of inputs, then validation creeps in, then nested objects, then dynamic lists, and before long you have three sources of truth that all disagree about what a "valid" form looks like: your component state, your validation rules, and your TypeScript types. Formisch takes aim at exactly that mess. It is a headless, schema-based form library that derives everything — state shape, validation, and types — from a single Valibot schema, and it does so the same way across React, Solid, Vue, Svelte, Preact, and Qwik.

The library comes from Fabian Hiller, the author of Valibot and of the earlier Modular Forms project, so the schema-first philosophy here is not a marketing afterthought. It is the entire point. You write one schema, and Formisch infers the input and output types of your whole form from it, including deeply nested fields and dynamic arrays, then hands you headless primitives to render however you like. This article focuses on the React package, @formisch/react, but the mental model carries over to every supported framework.

What Makes It Tick

A few ideas separate Formisch from the usual crowd of form helpers.

  • Schema is the single source of truth. Your Valibot schema defines the shape, the validation rules, and the types all at once. There is no separate "resolver" to wire in, and no risk of your types drifting away from your validation.
  • End-to-end type safety. Field paths, values, and errors are all typed from the schema. Even a deep path like ['todos', index, 'label'] gets full editor autocompletion, and the inference stays fast as forms grow.
  • Truly headless. Formisch ships no markup and no styles. It connects to plain native HTML inputs, and you own every pixel of the rendering.
  • Small and tree-shakeable. Bundles start around 2.5 kB because the manipulation methods are modular — you only ship the ones you import. The React package weighs in around 4.5 kB gzipped with zero runtime dependencies.
  • Fine-grained reactivity. Only the fields that actually changed re-render, rather than the whole form thrashing on every keystroke.
  • One model, many frameworks. A shared core compiles to each framework's native reactivity at build time, so the same concepts work in React, Solid, Vue, Svelte, Preact, and Qwik without a lowest-common-denominator abstraction.

That last point is the architectural bet. A shared engine holds the form-state logic, and at build time framework-specific reactivity is woven in, so the React package leans on React state while the Solid package leans on signals. The author describes the ambition as "Vite, but for forms" — one core, native performance everywhere.

Getting It Into Your Project

Formisch depends on Valibot as a peer, so install both. It also expects TypeScript 5 or newer, which makes sense given how much of the value lives in the type system.

npm install @formisch/react valibot

Or with yarn:

yarn add @formisch/react valibot

You install the framework package — @formisch/react here — and it pulls in the shared core and methods internally. You do not install @formisch/core yourself; it is an internal dependency, not the public entry point.

Your First Form

The smallest useful example is a login form. You define a Valibot schema, hand it to useForm, and then wire up fields. Here is the whole thing.

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 unpacking. useForm({ schema }) initializes the form store and infers every type from the schema. The <Form> component is a thin wrapper over a native <form> element that handles validation and submission; the output passed to onSubmit is already validated and fully typed as the schema's output. Each <Field> uses a render prop: field.props spreads the native event and ref bindings onto your input, field.input is the current value, and field.errors is the array of validation messages. Notice that email and password come straight from the schema's keys — misspell one and TypeScript complains immediately.

By default, validation runs on blur and on submit, which keeps things calm while the user is still typing. You can change that timing through validate and revalidate options in the useForm config if you want eager validation or a different rhythm.

Hooks Instead of Components

The <Field> render-prop component is convenient, but sometimes you want to connect an input from inside your own component logic rather than nesting a render function. Formisch provides a useField hook for exactly that, giving you the same props, input, and errors without the JSX wrapper.

import { useField, useForm } from '@formisch/react';

function EmailInput({ form }) {
  const field = useField(form, { path: ['email'] });

  return (
    <label>
      Email
      <input {...field.props} value={field.input} type="email" />
      {field.errors && <span>{field.errors[0]}</span>}
    </label>
  );
}

This is handy when you are building reusable input components or when the render-prop nesting gets in the way of readability. The hook and the component are two doors into the same room — pick whichever reads better at the call site.

Dynamic Lists Done Right

Field arrays are where most form libraries start to creak, and they are where Formisch's path-based design pays off. Say you have a to-do form where each item has a label and a deadline. You wrap the list in a <FieldArray> and manipulate it with standalone methods.

import { FieldArray, Field, insert, remove, move, swap } from '@formisch/react';

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

The thing to notice is the method signature. Array operations are standalone functions that take the form store as their first argument, like insert(form, options), rather than methods hanging off a hook return. That is what keeps them tree-shakeable — you only bundle insert if you actually import it. The full set covers everything you would expect for managing a list:

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

Because the path ['todos', index, 'label'] is typed against the schema, you get autocompletion all the way down and an error if you reference a field that does not exist. It reads a little more verbosely than the dot-string paths some other libraries use, but the payoff is that the compiler is checking your work at every level.

Reaching In Programmatically

Beyond the components and the array helpers, Formisch exposes a broad set of standalone methods for driving the form from your own code. They follow the same method(form, options) shape, so they stay modular and tree-shakeable. You can read and write values with getInput and setInput, manage errors with getErrors, getAllErrors, and setErrors, trigger validate or submit directly, move focus with focus, and start fresh with reset.

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

setInput(form, { path: ['email'], input: 'cat@example.com' });

await validate(form, { path: ['email'] });

reset(form);

This is the escape hatch for the cases that do not fit the declarative model: prefilling a form from an API response, clearing it after a successful submission, or running a one-off validation before some custom logic. Because each method is imported individually, reaching for these does not balloon your bundle.

An Honest Word on Maturity

It would be unfair to present Formisch without context. Every package is still in the 0.x range, and the repository is only about a year old. Breaking changes have landed between minor versions — recent releases tightened the type constraints on the core hooks and components — so you should expect some churn until a 1.0 settles things down. Adoption is real but modest, with React and Solid leading the way at a few thousand weekly downloads each, which is a different universe from the millions that React Hook Form sees.

There are two hard constraints to weigh. Validation is Valibot-only by design; there is no Zod or Yup adapter, so if your codebase has standardized on Zod you would either run two schema libraries or migrate. And the whole value proposition is built around TypeScript — in plain JavaScript, the typed paths and inference that make Formisch special largely evaporate. None of these are flaws exactly, but they are commitments you are signing up for.

Should You Reach For It

Formisch is a sharp answer to a real problem. If you are already using Valibot, or want to, and you like the idea of your form's types flowing from the same schema that validates it with zero glue code, the fit is excellent. The same goes for teams shipping across multiple frameworks who want one form mental model everywhere, and for anyone who cares about bundle size and fine-grained performance while keeping full control of the markup. The author's track record with Valibot and Modular Forms is genuine reason for confidence in where this is headed.

If you need rock-solid stability today, you are committed to Zod, you are not on TypeScript, or you depend on a mature ecosystem of devtools and integrations, then React Hook Form or TanStack Form remain the safer picks for now. But Formisch is one of the more thoughtfully designed form libraries to appear recently, and the schema-first idea at its heart is compelling enough to keep an eye on as it marches toward 1.0.