# Formisch > The lightweight, schema-first and fully type-safe form library for Preact, Qwik, React, Solid, Svelte and Vue. ## Posts of 2026 ### Building a Dynamic Invoice Form in Svelte 5 with Formisch Published on July 16, 2026 by [flySewa](https://github.com/flySewa) Svelte 5 gives us powerful primitives for managing state with runes, but forms still have their own set of challenges. A field is not only a value. It has validation state, errors, transformations, and a relationship with the rest of the form. When a form grows beyond a handful of inputs, things like nested data, dynamic fields, and derived values all need to stay in sync. At this point, form state management moves away from handling individual inputs and towards managing the entire form model. Formisch approaches this by treating the form as a single connected model. It's a headless, lightweight library where the schema, state, validation, and submitted output all come from one source, while you stay in full control of the UI. To see this in practice, we'll build a dynamic invoice form and walk through how Formisch works with Svelte 5's rune-based reactivity to manage: - Invoice details - Client information - Dynamic line items - Validation - Live totals - Typed submission output By the end, you'll have a working invoice form that handles nested data, dynamic fields, validation, and typed output. The focus here is on the form architecture: how state, validation, and dynamic fields fit together. The styling and project setup are kept minimal on purpose. **Prerequisites:** This tutorial assumes you already have a Svelte 5 project set up and are familiar with Svelte components and runes. We'll be using: - Svelte 5 - [Formisch](https://formisch.dev) - [Valibot](https://valibot.dev) for schema validation You can follow along here, view the complete example on [Stackblitz](https://stackblitz.com/edit/sveltejs-kit-template-default-g5kpabcj?file=src%2Froutes%2F%2Bpage.svelte) , or clone the [runnable example](https://github.com/open-circle/formisch/tree/main/examples/svelte-invoice-form) from the repo. #### Step 1: Install dependencies We'll start by installing Formisch and Valibot: ```bash npm install @formisch/svelte valibot ``` #### Step 2: Define the form model Before connecting inputs, we need to define what our invoice form looks like. The form model describes the structure of our data: the fields the user can edit, the validation rules for each field, and the shape of the final output after submission. Instead of defining validation separately and then recreating the same structure as TypeScript types or default values, Formisch can use a schema as the source of truth. Our invoice needs basic metadata, client details, and a list of line items. We'll define those requirements with Valibot: ```ts import * as v from 'valibot'; const moneyInput = (message: string) => v.pipe( v.string(), v.nonEmpty(message), v.toNumber(), v.minValue(0, 'Amount cannot be negative') ); const positiveNumberInput = (message: string) => v.pipe( v.string(), v.nonEmpty(message), v.toNumber(), v.minValue(1, 'Value must be at least 1') ); const InvoiceSchema = v.object({ invoiceNumber: v.pipe(v.string(), v.nonEmpty('Invoice number is required')), issueDate: v.pipe(v.string(), v.nonEmpty('Issue date is required')), dueDate: v.pipe(v.string(), v.nonEmpty('Due date is required')), client: v.object({ name: v.pipe(v.string(), v.nonEmpty('Client name is required')), email: v.pipe( v.string(), v.nonEmpty('Client email is required'), v.email('Enter a valid email address') ), }), lineItems: v.pipe( v.array( v.object({ description: v.pipe(v.string(), v.nonEmpty('Description is required')), quantity: positiveNumberInput('Quantity is required'), unitPrice: moneyInput('Unit price is required'), }) ), v.minLength(1, 'Add at least one invoice item'), v.maxLength(10, 'You can only add up to 10 invoice items') ), taxRate: v.pipe( v.string(), v.nonEmpty('Tax rate is required'), v.toNumber(), v.minValue(0, 'Tax cannot be negative'), v.maxValue(100, 'Tax cannot be more than 100%') ), discount: moneyInput('Discount is required'), notes: v.optional(v.string()), }); ``` The invoice form has fields like `quantity`, `unitPrice`, and `taxRate` that represent numbers in the final invoice data, but the values coming from the inputs are still part of the form's input state. Instead of converting those values in separate event handlers or before submission, we keep that logic with the field definition. The schema handles the transition from input values to the validated data shape we actually want to submit. The `lineItems` field is modeled as an array because an invoice can contain a changing number of items. Each item has its own fields and validation rules, while the array itself has rules like the minimum and maximum number of items allowed. This keeps the form model as the place where the shape of the data is defined. When a field changes, gets added, or gets removed, the validation rules and submitted output stay connected to that same structure. #### Step 3: Create the form With the form model defined, we can create the form state from that schema. `createForm` connects the schema to the reactive form state. From this point, Formisch can use the same structure for fields, validation, and submission. ```ts import { createForm } from '@formisch/svelte'; const invoiceForm = createForm({ schema: InvoiceSchema, initialInput: { invoiceNumber: 'INV-001', issueDate: new Date().toISOString().slice(0, 10), dueDate: '', client: { name: '', email: '', }, lineItems: [ { description: '', quantity: '1', unitPrice: '0', }, ], taxRate: '7.5', discount: '0', notes: '', }, }); ``` The initial values represent the input state of the form, not the final invoice object. That is why numeric values are still strings here β€” the user is editing input values, and the schema transformation handles converting them when the form is validated. Starting with one `lineItem` also matches the validation rule from the schema. Since the invoice requires at least one item, the form begins in a state that already satisfies that constraint. With the form created, the next step is to connect individual fields to that state. #### Step 4: Connect fields Formisch is headless by design and does not decide what your inputs should look like. Instead, it gives you the state and bindings needed to connect your own markup. A field is connected through the `Field` component: ```svelte {#snippet children(field)} {#if field.errors}

{field.errors[0]}

{/if} {/snippet}
``` The `path` tells Formisch where this field exists in the form model. For nested values, the path follows the same structure as the schema: ```svelte ``` This means the component structure and the data structure stay aligned. The field already knows how to read its value, update the form state, and expose validation results. You don't need separate bindings or error state for every input. Each field remains connected to the form model it belongs to. #### Step 5: Add dynamic line items Line items are where forms usually become more complex. The number of rows is not fixed, and each row has its own fields and validation state. Instead of manually keeping track of indexes, values, and errors when items are added or removed, Formisch provides `FieldArray` to keep the collection connected to the form model. ```svelte {#snippet children(fieldArray)} {#each fieldArray.items as item, index (item)}
Item {index + 1} {#snippet children(field)} {#if field.errors}

{field.errors[0]}

{/if} {/snippet}
{#snippet children(field)} {#if field.errors}

{field.errors[0]}

{/if} {/snippet}
{#snippet children(field)} {#if field.errors}

{field.errors[0]}

{/if} {/snippet}
{/each} {#if fieldArray.errors}

{fieldArray.errors[0]}

{/if} {/snippet}
``` `FieldArray.items` represents the rows currently tracked by the form. When an item is inserted or removed, Formisch updates the related field state along with it. The field paths follow the same structure as the schema: ```ts ['lineItems', index, 'description'] ``` That means the UI structure mirrors the data structure. A row in the interface maps directly to an item in the form state, so validation and updates stay attached to the correct fields. To add and remove items: ```ts import { insert, remove } from '@formisch/svelte'; function addLineItem() { insert(invoiceForm, { path: ['lineItems'], initialInput: { description: '', quantity: '1', unitPrice: '0', }, }); } function removeLineItem(index: number) { remove(invoiceForm, { path: ['lineItems'], at: index, }); } ``` The remove action is disabled when only one item remains because the schema requires at least one line item. The UI behavior and validation rules are coming from the same model. #### Step 6: Add reactive totals The invoice total depends on several values in the form: quantities, prices, tax, and discounts. Instead of updating totals manually inside every input handler, we can derive only the fields the calculation depends on. Svelte 5's `$derived` keeps those values connected to the form state, so the totals update automatically whenever one of the relevant fields changes. ```ts import { getInput } from '@formisch/svelte'; function toNumber(value: unknown): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } const taxRate = $derived( getInput(invoiceForm, { path: ['taxRate'], }) ); const discount = $derived( getInput(invoiceForm, { path: ['discount'], }) ); const lineItems = $derived( getInput(invoiceForm, { path: ['lineItems'], }) ); const totals = $derived.by(() => { const subtotal = lineItems.reduce( (sum, item) => sum + toNumber(item.quantity) * toNumber(item.unitPrice), 0 ); const tax = subtotal * (toNumber(taxRate) / 100); const discountAmount = toNumber(discount); const total = Math.max(subtotal + tax - discountAmount, 0); return { subtotal, tax, discount: discountAmount, total, }; }); ``` `getInput` lets us derive individual fields from the form state. Here we derive `taxRate`, `discount`, and `lineItems` separately, then use `$derived.by` to calculate the invoice totals. The small `toNumber` helper keeps the calculation stable while a user is editing numeric fields, so the UI never briefly displays `NaN` if an input is temporarily empty. Because each derived value stays connected only to the field it represents, the totals only recompute when one of those values changes. Changes to unrelated fields elsewhere in the form won't trigger the calculation, keeping the derived state focused on exactly the data it needs. For individual row totals, we can reuse the same derived `lineItems` state: ```ts function getLineTotal(index: number): number { const item = lineItems[index]; if (!item) return 0; return toNumber(item.quantity) * toNumber(item.unitPrice); } ``` Because `lineItems` stays connected to the form state, each row total updates automatically whenever that item's quantity or unit price changes. #### Step 7: Submit the form At this point, the form state, validation, and fields are all connected. The final step is handling the transition from editable input values to the validated invoice data your application can use. By default, Formisch runs validation on submission and provides the parsed output from the schema. This behavior can be configured if your application needs a different validation strategy. ```ts import { type SubmitHandler } from '@formisch/svelte'; type InvoiceOutput = v.InferOutput; let submittedInvoice = $state(null); const submitInvoice: SubmitHandler = (output) => { submittedInvoice = output; console.log('Invoice submitted:', output); }; ``` Then connect the submit handler to the form: ```svelte
``` The value received here is the validated schema output, not just the raw values from the inputs. That means fields like `quantity`, `unitPrice`, `taxRate`, and `discount` have already gone through their transformations. The form moves from input state to application data at the validation boundary. The form state also exposes submission and validation status, so the UI can react without maintaining separate loading flags. To reset the form: ```ts import { reset } from '@formisch/svelte'; function resetInvoice() { submittedInvoice = null; reset(invoiceForm); } ``` Resetting restores the initial state and clears the form state in one operation. #### What we built The invoice form now includes: - A schema that defines the form structure and validation rules - Field-level state management through `Field` - Dynamic collections through `FieldArray` - Derived values using Svelte 5 reactivity - Validated and transformed output on submission The important part is that these pieces are not separate systems. The schema defines the shape, fields connect the UI, and submission produces the validated result from the same model. Instead of manually syncing inputs, errors, and derived values, the form stays connected throughout its lifecycle. #### When to use Formisch Formisch is a good fit when your form logic mostly lives on the client and you want a **schema-native** approach to building forms. Instead of defining your data shape, validation rules, transformations, and submitted output separately, the schema becomes the source of truth for the entire form. Fields, validation, and typed output all stay connected to that same model, reducing duplication and the amount of manual synchronization needed as your forms grow. This becomes especially useful when working with nested objects, dynamic collections, complex validation rules, or derived values that update as the user types. Rather than wiring those pieces together yourself, they continue to follow the same schema-driven structure. Because Formisch is built for Svelte 5, it also fits naturally with rune-based reactivity. Form state can participate in `$derived` calculations and other reactive logic without introducing another state management layer. It is also intentionally headless. Formisch does not provide pre-built inputs or decide how your UI should look. You control the markup and connect it to the form state. This works well when you already have a component system or design language and want the form layer to stay separate from your UI. Native SvelteKit support is also planned, including support for progressively enhanced forms. If your main requirement today is a server-first workflow, a library built specifically for server actions may be a better fit until those capabilities arrive in Formisch. #### What's next for Formisch Formisch is currently in [RC](https://formisch.dev/blog/formisch-v1-release-candidate/), with v1 approaching. As we prepare for v1, feedback from developers building real forms is especially useful. If you try Formisch in your own projects, let us know what works well, what feels unclear, and what you'd like improved. For a deeper look at the ideas behind Formisch and how it works internally, you can read the [architecture post](https://formisch.dev/blog/one-core-six-frameworks/). If you're comparing form libraries, our [comparison guide](https://formisch.dev/svelte/guides/comparison/) looks at how Formisch differs from other approaches in the Svelte ecosystem, including Superforms and TanStack Form. ### Formisch v1 RC is now available Published on June 23, 2026 by [fabian-hiller](https://github.com/fabian-hiller), [flySewa](https://github.com/flySewa) Formisch is officially in the Release Candidate (RC) phase! πŸŽ‰ This means the core API and the overall design are now stable. Barring a critical issue, there will be no breaking changes before the 1.0 release. So if you have been waiting for Formisch to stabilize before trying it, now is a good time. We have spent the last few months testing the library against complex edge cases to improve reliability and runtime performance. Your feedback on the current API is the most valuable thing we can get before v1 locks in. #### So, what actually is Formisch? Formisch is a schema-first, headless, fully type-safe library for managing form state. You describe your form once with a [Valibot](https://valibot.dev/) schema, and that single schema drives both runtime validation and your TypeScript types. You don't need separate type definitions to keep in sync or a resolver to configure. ```tsx import * as v from 'valibot'; const LoginSchema = v.object({ email: v.pipe(v.string(), v.email('Please enter a valid email.')), password: v.pipe(v.string(), v.minLength(8, 'Your password is too short.')), }); ``` From there you build your UI on top of the schema. Formisch is headless, so you keep full control over your markup and styling. Every field gives you its value, its errors, and the props to wire up your input: ```tsx import { Field, Form, useForm } from '@formisch/react'; export default function LoginForm() { const loginForm = useForm({ schema: LoginSchema }); return (
{ // `values` is fully typed: { email: string; password: string } console.log(values); }} > {(field) => (
{field.errors &&
{field.errors[0]}
}
)}
{(field) => (
{field.errors &&
{field.errors[0]}
}
)}
); } ``` You can run this exact example on the [playground](/playground/login/) in your browser, no install required. The `path` is fully typed against your schema, so if you rename a field, TypeScript flags every place that no longer matches. Because state lives in fine-grained signals, only the fields that actually change re-render. And thanks to the modular design, the bundle size starts at around 2.5 kB. Formisch runs on React, Vue, Solid, Qwik, Svelte, and Preact from a single core, but there is no adapter tax. At build time it swaps in your framework's own reactivity, so you get real React updates, real Solid signals, and native performance with no extra layer in your bundle. For a deeper look at the design decisions behind Formisch, read our [architecture post](/blog/one-core-six-frameworks.md). We explain how the runtime works and why there is no abstraction layer. Curious how it compares to popular form libraries? Our comparison guides break down the differences in API design and mental models for [React](/react/guides/comparison.md), [Solid](/solid/guides/comparison.md), [Svelte](/svelte/guides/comparison.md), and [Vue](/vue/guides/comparison.md). #### How we got here Formisch did not start as Formisch. It started years ago with a frustration I think every web developer knows: you build a form that should be simple, and it grows into hundreds of lines of repetitive, fragile code. My first real answer to that was [Modular Forms](https://modularforms.dev/), which I started in 2022. It was built around one idea: you should only ever ship the form code you actually use. Instead of one giant hook that does everything, the functionality is split into small, tree-shakeable pieces. It started on SolidJS, later came to Qwik and other frameworks, and it taught me what a great forms experience feels like. The second piece came a year later, in 2023: [Valibot](https://valibot.dev/), the schema library I built next. Valibot made it possible to describe the shape of your data once and get both runtime validation and static TypeScript types from a single source. That turned out to be the missing foundation. If one schema can drive validation and types, it can drive an entire form. Formisch is where those two threads meet. It is basically the rewrite of Modular Forms I had wanted to do for a long time, rebuilt around a schema-first, framework-agnostic core. For a while I wasn't sure it was even technically possible, or worth the enormous amount of work it would take. I built the first version of the core by hand, before coding agents were any help, in late nights after my actual day job. Honestly, what kept it alive was a promise. In late 2024 I told [Shai Reznik](https://github.com/shairez) from the Qwik team that I was going to build this, and then I kept telling him, month after month, how it was going. That accountability, and his constant encouragement, is probably the single biggest reason Formisch exists today. The name came out of one of those early chats too. _Formisch_ is a little German pseudo-word: "form" plus the German "-isch" suffix that turns a noun into a describing word. It did not come easily. My first architecture was a dead end. I tried to map the form's signal-based store onto a tree structure and ran straight into more edge cases than I could handle. I had to throw it away, go back to research, and start over. It took weeks to crack the part I am most proud of: efficient, per-field signals that work natively across every supported framework. When that finally clicked, Formisch became real. The vision behind all of this is bigger than any single library. I want Formisch to be a framework-agnostic platform for forms, basically _like Vite, but for forms_. Form logic should be something you build once and reuse everywhere, instead of rebuilding it for every framework and every layer. The [architecture post](/blog/one-core-six-frameworks.md) goes deep on how that one-core-six-frameworks design actually works. #### What's new in the Release Candidate Getting to RC was less about adding features and more about hardening the ones already there, now backed by a test suite with 100% coverage: - **Hardened nested fields, field arrays, and tuples.** Lots of edge cases around inserting, moving, replacing, resetting, and resizing nested structures β€” the parts of a form that are easiest to get subtly wrong. - **More correct state and focus handling.** Dirty, touched, and the new edited state now behave predictably across resets and field-array operations. On a failed submit, focus moves to the first field that can actually receive it. - **New convenience helpers.** Methods like [`isValid`](/methods/api/isValid.md), [`isDirty`](/methods/api/isDirty.md), and [`getDeepErrors`](/methods/api/getDeepErrors.md). There is also an [`emptyInput`](/core/api/EmptyInput.md) config that gives required fields a sensible starting value, so an empty field shows the expected error message. If something behaves unexpectedly in your forms, that feedback is the most valuable thing you can give us before v1 locks in. #### Give it a try Install it for your framework. Here is the React package as an example. Every other framework works the same way, just swap in `@formisch/solid`, `@formisch/vue`, `@formisch/svelte`, `@formisch/preact`, or `@formisch/qwik`: ```bash npm install @formisch/react valibot ``` Take a look at the [playground](/playground/login/), which runs in your browser with no install, or open a [StackBlitz example](https://stackblitz.com/edit/formisch-playground-react) to get a feel for it. If you find any bugs or something does not work the way you expect, [open an issue](https://github.com/open-circle/formisch/issues) or find us on [Discord](https://discord.gg/tkMjQACf2P). That feedback is exactly what helps us get v1 right. If Formisch is useful to you, a [GitHub star](https://github.com/open-circle/formisch) helps others discover the project and signals that this direction is valuable. πŸ™ A huge thank you to all our early testers and code contributors who helped us refine the API to this point, and to Shai Reznik and the Qwik community for the encouragement along the way. We really appreciate your support. ### One core, six frameworks, zero runtime abstraction Published on May 22, 2026 by [fabian-hiller](https://github.com/fabian-hiller) Most "framework-agnostic" libraries are agnostic at runtime. They ship their own reactivity primitive (a custom store, an observer protocol, a subscription bus) and bridge it into whichever framework you're using. That bridge has a real cost: extra bytes in your bundle, an adapter layer your framework has to drive, and no integration with the framework's own batching or scheduling. Formisch, a schema-first form library for SolidJS, React, Vue, Svelte, Preact, and Qwik, takes a different approach. Instead of a runtime reactivity layer, it swaps in the framework's native primitive at build time. In Solid, form state is real Solid signals. In Vue, real `shallowRef`s. In Svelte, real `$state` cells. No runtime adapter or abstraction tax. This post is about why that distinction matters and how the design works. #### The four-function contract Almost every file in Formisch's core only ever imports four functions: `createSignal`, `batch`, `untrack` and `createId`. None of them know about a specific framework. They're declared once, abstractly. `createSignal` returns a `Signal` that looks like this: ```ts interface Signal { get value(): T; set value(nextValue: T): void; } ``` Reads track, writes notify. That's the entire reactive contract the core relies on. Every field store in your form is built out of `Signal` instances: one for `errors`, one for `isTouched`, one for `isDirty`, one for `input`, and so on. The shape is pre-allocated at form creation from your Valibot schema, so methods like `setInput` and `validate` don't look up paths in a generic object. They walk a typed tree of stores and write `.value` directly. Only the components that read that specific signal re-render. The trick is that the core never picks an implementation of `Signal`. It just imports it. #### The build-time swap Inside the core there's a single [`framework/index.ts`](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.ts) that exposes the four functions. Next to it, six siblings: `index.solid.ts`, `index.react.ts`, `index.vue.ts`, `index.svelte.ts`, `index.preact.ts` and `index.qwik.ts`. None of them ship to your app on their own. The switch happens at build time. When the bundler resolves an import of `./framework/index.ts`, a small [rolldown plugin](https://github.com/open-circle/formisch/blob/main/packages/core/tsdown.config.ts) checks whether a sibling `./framework/index.{framework}.ts` exists. If it does, the plugin redirects the import there. The build runs once per framework with a different target, and each run produces a self-contained bundle with that framework's adapter inlined. Because the framework target is baked in at build time, there's no runtime detection: no `import.meta` checks, no environment sniffing, and no adapter to load. The same `createFormStore` code path that runs in your Solid app runs in your Vue app. It just imports a different `createSignal` because the bundle it lives in was compiled with a different target. #### What the adapters actually look like Because most modern frameworks already have a signal primitive that fits the `.value` getter/setter shape, most of the adapters are nearly invisible. Here's the reactivity glue from four of them: **Vue** ([`index.vue.ts`](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.vue.ts)): ```ts export { shallowRef as createSignal } from 'vue'; ``` **Preact** ([`index.preact.ts`](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.preact.ts)): ```ts export { signal as createSignal, untracked as untrack, batch, } from '@preact/signals'; ``` **Qwik** ([`index.qwik.ts`](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.qwik.ts)): ```ts export { createSignal, untrack } from '@qwik.dev/core'; ``` **Solid** ([`index.solid.ts`](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.solid.ts)) needs a thin wrapper because its tuple-style `[get, set]` signal doesn't quite match the `.value` shape, but it stays under twenty lines: ```ts import { createSignal as signal } from 'solid-js'; export function createSignal(initialValue: T): Signal { const [getSignal, setSignal] = signal(initialValue); return { get value() { return getSignal(); }, set value(nextValue: T) { setSignal(() => nextValue); }, }; } ``` **Svelte** ([`index.svelte.ts`](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.svelte.ts)) is similarly small, a thin wrapper around the `$state.raw` rune that exposes the same `.value` getter/setter. The one exception is **React**, because React doesn't have a native signal primitive. [Its adapter](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.react.ts) hand-rolls a small pub/sub (around a hundred lines, including batching) and a companion [`useSignals`](https://github.com/open-circle/formisch/blob/main/frameworks/react/src/hooks/useSignals/useSignals.ts) hook in the React wrapper registers your component as a listener and force-updates it when any signal it read changes. Even so, the React adapter is just enough pub/sub to drive re-renders. There's no separate "Formisch reactivity layer" the bundle has to carry, only the minimum that React would need anyway. In every case, your form state lives in something the framework's own scheduler already knows how to track. #### What you get for free Bundle size is the obvious benefit. But it's not the most important one. **Framework-native integration.** In Solid, your form state participates in `createMemo` and `untrack` natively. In Vue, it works with `computed` and `watch`. In Svelte, it slots into `$derived`. In Preact and Qwik, it works with their respective `computed` primitives. Because the signals _are_ the framework's signals, there's nothing to bridge and your batching, scheduling and fine-grained tracking come along for free. **Tree-shakeable everything.** Each form operation (`setInput`, `validate`, `reset`, `insert`, `move`, `focus` and the rest) lives in its own module. A form that doesn't call `reset` doesn't ship `reset`. A form that only renders fields doesn't ship `validate`. The core stays small; everything else is opt-in. **Type safety from one source.** The same Valibot schema you pass to the form drives both runtime parsing _and_ the inferred TypeScript types for paths, inputs and outputs. There is no second declaration to keep in sync, no resolver to configure, no helper type to import. Rename a field in your schema and every `` in your codebase is type-checked against the new shape immediately. **One library, six communities.** Because the core is genuinely shared, an improvement in Svelte benefits React, Vue, Solid, Preact and Qwik at the same time. Same for bug fixes and performance work. There's one implementation of the recursive store builder, one validation orchestration, one set of methods and every framework community benefits. Framework-agnostic is usually framed as a trade-off: you give up framework-native integration in exchange for portability. In Formisch's case, that trade-off doesn't exist. Building the abstraction at the source code level instead of the runtime level means you get both. #### Try it out The [playground](/playground/login/) runs in your browser, no install needed. When you're ready, pick your framework's guide and you'll have a typed form running in minutes. If you want to keep going, every framework has its own architecture guide that walks through what happens when you call `createForm` or `useForm`, from the recursive store builder to the public form store. The full source is on [GitHub](https://github.com/open-circle/formisch), and contributions from any framework's community benefit all of them. ### Choosing a React Form Library in 2026: React Hook Form, TanStack Form, and Formisch Compared Published on April 9, 2026 by [flySewa](https://github.com/flySewa) [TanStack Form](https://tanstack.com/form/latest) has been gaining adoption as an alternative to [React Hook Form](https://react-hook-form.com), especially for teams building complex, type-safe forms in React. This has led to more direct comparisons between TanStack Form and React Hook Form, particularly when deciding whether switching is worth the effort. Whether you should switch from React Hook Form, start with TanStack Form on a new project, or consider alternatives like [Formisch](https://formisch.dev) depends on how your forms are structured, how complex they are, and what the migration cost looks like in practice. This article doesn't walk through every API. The official docs already cover that. Instead, it compares React Hook Form (RHF), TanStack Form, and Formisch across the areas that influence real world decisions: TypeScript inference, validation architecture, and performance as forms grow in complexity. If you're evaluating React form libraries or deciding between TanStack Form and RHF, this gives you a clear, practical understanding of how each one behaves so you can choose the right approach for your stack. #### TL;DR - **RHF types and schemas are separate.** The TypeScript generic you pass to `useForm` and your validation schema are two distinct things with no connection at runtime. - **TanStack Form types are inferred from `defaultValues`.** When you use a schema for runtime validation, TypeScript types still come from your default values, not the schema. - **Formisch types come directly from the schema.** The Valibot schema is both the runtime validator and the TypeScript type source. - **RHF validation is field-centric.** Each field owns its rules. Validation timing is a single form-wide setting. Async loading state is managed in your component. - **TanStack Form validation is per-validator.** Each validator specifies its own trigger and manages its own async state. All of this is explicitly configured. - **Formisch validation rules live entirely in the schema.** Rules are defined in one place without field-level validators or resolver setup. - **RHF stores values in RHF's internal object, updating the DOM directly**. Components that need to react to value changes subscribe through `watch` or `useWatch`, with different re-render implications for each. - **TanStack Form and Formisch both scope re-renders automatically.** Both only update what actually changed. Formisch uses signals, which can reduce overhead in large or highly dynamic forms. > Starting with TypeScript inference gives the clearest picture of how each library is architecturally different, because it affects everything downstream. #### TypeScript inference All three libraries support TypeScript. The more relevant question is where the types come from and how much work is required to keep them accurate as the form changes. ##### React Hook Form [RHF asks you to declare the form's shape as a TypeScript type and pass it in as a generic](https://react-hook-form.com/ts): `useForm()`. That generic is a TypeScript-only construct. It exists purely at compile time and is completely absent at runtime. The validation schema you configure through a resolver (whether that's Zod, Valibot, Yup, or anything else) is a separate runtime object with no connection to each other. TypeScript has no enforced connection between the type you passed to `useForm` and the schema you passed to the resolver. You can have a type with ten fields and a schema that only validates seven of them, and TypeScript will not flag it. Keeping them aligned is entirely your responsibility, and there is no mechanism to enforce that they stay that way. ```ts import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; // Thing 1 - your TypeScript type, written manually type MyFormValues = { email: string; password: string; username: string; // you added this field }; // Thing 2 - your validation schema, completely separate const schema = z.object({ email: z.string().email(), password: z.string().min(8), // you forgot to add username here // nothing will warn you }); // You pass both to RHF separately const form = useForm({ resolver: zodResolver(schema), }); ``` If username is never validated, the form will still submit and TypeScript will not catch this. Most teams handle this by deriving the TypeScript type directly from the schema using the library's inference utilities (Zod's `z.infer`, Valibot's `InferInput`, and so on) rather than writing the type manually. That removes the duplication, but it doesn't change the underlying architecture. The type and the schema are still two separate things that happen to share the same shape because you've wired them together. The resolver configuration that connects them at runtime is still something you set up yourself. The other area where this shows up is field path typing, RHF type-checks the string paths you pass to `register`, `setValue`, `watch`, and similar APIs against the generic type. This works for flat or moderately nested structures. With deeply nested objects, discriminated unions, or complex conditional field types, the path inference can produce `any` where you'd expect a concrete type. `useFieldArray` in particular has had issues here, especially when the array items themselves have nested or optional fields. RHF gives you full control over your type definitions, but keeping them in sync with your validation schema is work you own entirely. ##### TanStack Form [TanStack Form infers the form's shape from the `defaultValues` you provide when calling `useForm`](https://tanstack.com/form/latest/docs/framework/react/guides/basic-concepts). Mechanically, this means TanStack Form's internals construct a type at the point of form creation, and every subsequent API (field access, value reads, validation state) is typed against that inferred shape. There is no generic to pass in separately, because the form already knows its own shape from the values you gave it. If you change the default values, the types change with them. ```ts // No separate type needed // the type comes automatically from defaultValues const form = useForm({ defaultValues: { email: '', password: '', username: '', }, }); ``` Adding a field means updating one thing, and the type follows automatically. TanStack Form also supports schema libraries like Zod and Valibot through [Standard Schema](https://standardschema.dev). You can pass them through the `validators` option for runtime validation. However, the TypeScript types are still inferred from `defaultValues`, not from the schema. If your schema and your `defaultValues` describe the same shape, they stay in sync. If they drift, TypeScript may surface errors depending on how the types overlap, meaning you still have two things to keep aligned even when using a schema. Note that the form's shape is anchored to whatever you provide as defaults. For optional fields or fields that start with no value at all, you need to provide a default that accurately represents the full range of valid types for that field, or the inference won't reflect it correctly. This also applies to fields that need to accept more than one type. Input that should hold either a string or a number does not naturally express that through a default value alone. HTML inputs always return strings, so the default will always appear as a string to the form. Handling this requires manual type casting in the `onChange` handler and a union schema to validate the full range of accepted types. The form does not infer the union from the default value. If you use a validation schema alongside your default values, which most teams do, those two things still have no enforced connection at the TypeScript level. There is still no single source of truth between `defaultValues` and your schema. In practice, mismatches can surface depending on how validators are typed, but there is no inherent guarantee they stay aligned. In summary, type maintenance in TanStack Form comes down to keeping your `defaultValues` accurate, and the form derives everything else from there. ##### Formisch [Formisch](https://formisch.dev) is built directly on top of a schema library, which is why its type inference behaves differently from the other two. It currently supports only [Valibot](https://valibot.dev), but could theoretically be extended to support additional schema libraries such as [Zod](https://zod.dev). Valibot schemas carry TypeScript type information as part of their construction. When you build a schema, you are simultaneously constructing a runtime validator and a compile-time type description, and these are not two separate things that are kept in sync. They are the same object. [Formisch reads the TypeScript types directly off the schema at compile time, without a separate generic, inference utility, or resolver to configure](https://formisch.dev/react/guides/typescript/). When the schema changes, the types change everywhere in the form automatically, because the types were never anything other than a direct reading of the schema. ```ts import { useForm } from '@formisch/react'; import * as v from 'valibot'; // One thing. That's it. const schema = v.object({ email: v.pipe(v.string(), v.email()), password: v.pipe(v.string(), v.minLength(8)), username: v.string(), }); // No type to write. No resolver to configure. // No defaultValues to keep in sync. const form = useForm({ schema }); ``` When you add a field to the schema, it exists everywhere: in the type, in the validation, in the form. When you remove a field, it disappears everywhere. There is no second place to update and no way for the two to silently drift apart, because there is no two. With Formisch, type accuracy is a function of schema accuracy. There is nothing else to maintain. For smaller forms the differences here are mostly cosmetic. As forms get larger (more fields, more conditional logic, more nested structures) RHF requires maintaining alignment between multiple pieces. TanStack Form reduces that to keeping your default values accurate, though schema drift is still possible if you use schema-based validation alongside it. Formisch takes a different approach, where the schema becomes the primary source of truth and the rest of the form derives from it. | | Type source | What you maintain | | ----------------- | ----------------------------- | ------------------------------- | | **RHF** | Generic you declare | Type + schema + resolver wiring | | **TanStack Form** | Inferred from `defaultValues` | Default values + schema if used | | **Formisch** | Valibot schema | Schema only | > Next, we'll look at how each library structures validation, which is where those differences become most visible in practice. #### Validation model Validation logic determines how much your components know about your form's rules, how much work you do when those rules change, and how your codebase holds up as the form gets more complex. We'll look at three aspects of this: where validation rules are defined, when they run, and how async validation is handled. RHF, TanStack Form, and Formisch answer all three questions differently, and those differences matter more as the form grows. ##### React Hook Form [RHF attaches validation rules at the point of field registration](https://react-hook-form.com/docs/useform/register). When you call `register("email", { required: true, pattern: /.../ })`, those rules live on that field call. Alternatively, you can pass a schema resolver at the form level using Zod, Valibot, Yup, or similar, which maps validation rules to field names through the resolver's output. Either way, the mental model is field-centric. Each field is responsible for its own validation, and the form is an aggregation of those field-level concerns. [Validation timing is controlled by a single `mode` option set on the form](https://react-hook-form.com/docs/useform): `onSubmit`, `onBlur`, `onChange`, `onTouched`, or `all`. RHF also provides `reValidateMode`, which determines how fields with errors behave after the first submission attempt. Both options apply to every field in the form, which means there is no way to say "validate this field on blur but validate that one on change" without writing custom `onChange` handlers yourself. ```ts import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; // Need username to validate while typing instead? // There is no built-in per-field timing configuration. Achieving this requires custom event handling. ``` A form with ten fields and three different validation requirements means three custom event handlers, each written by hand. Async validation is supported. You can return a Promise from a `validate` function. [RHF provides an `isValidating` state that tells you when async validation is running](https://react-hook-form.com/docs/useform/formstate), but showing a loading indicator to the user is still your responsibility. RHF's validation model is explicit and familiar, but the field-centric architecture distributes validation logic across your component tree and limits timing control to a form-wide setting. ##### TanStack Form [The most significant architectural change in TanStack Form is that validators are first-class objects](https://tanstack.com/form/v1/docs/framework/react/guides/validation). Each validator you define specifies its own trigger: `onChange`, `onBlur`, `onSubmit`, or `onMount` and these triggers are independent of each other. This matters more in real-world forms as different fields have different validation requirements. A password strength indicator should update on every keystroke. A username availability check should wait until the user leaves the field. An agreement checkbox only needs validation on submit. In RHF, combining these behaviors requires custom event handling. In TanStack Form, each validator defines its own timing. ```tsx // Each field defines its own timing independently checkUsernameAvailable(value), onChangeAsyncDebounceMs: 500, // waits before hitting the server }} /> checkPasswordStrength(value), }} /> !value ? 'Required' : undefined, }} /> ``` Validation timing is configuration, not custom code, and each validator specifies its own trigger independently. Async validation is also treated as a first-class concern. [An async validator automatically exposes an `isValidating` boolean on the field state](https://tanstack.com/form/latest/docs/framework/react/guides/validation), which you can use to render loading UI. TanStack Form manages the async lifecycle and exposes the state you need. It also supports debouncing for async validators through `onChangeAsyncDebounceMs` or `asyncDebounceMs`. This prevents unnecessary server requests when validation is triggered on input changes, but it requires explicit configuration. Form-level validators are also part of the core design. These validators run against the entire form state and can assign errors to specific fields. This avoids the manual `trigger` calls that are often required in RHF for cross-field validation. TanStack Form gives you fine-grained control over validation timing and async behavior, but requires more explicit configuration to get there. ##### Formisch Formisch centralizes everything in the Valibot schema. The component registers a field name and everything else comes from the schema, without field-level validators, or resolver setup. ```ts import { useForm } from '@formisch/react'; import * as v from 'valibot'; const schema = v.object({ username: v.pipe(v.string(), v.minLength(3)), password: v.pipe(v.string(), v.minLength(8)), agree: v.literal(true), }); const form = useForm({ schema }); ``` Validation runs when the form is submitted by default, rather than on individual field events like `onChange`. The `
` component intercepts the submit event, runs the schema against all current values, and blocks submission if anything fails. After that first submission attempt the validation re-runs as the user types, giving users silent-until-submit behaviour on first interaction, then live feedback as they correct mistakes. For most forms, this covers the range of timing behaviour users expect. > Formisch lets you configure this behaviour at the form level via the `validate` and `revalidate` options of `useForm`. Async validation follows the same pattern. A username availability check, for example, requires calling your server. In TanStack Form, that async logic lives on the field itself. In Formisch, it lives in the schema using `pipeAsync` and `checkAsync`. The field component is unchanged and just reads `field.errors`. Rather than wiring up a per-field async validator with its own trigger and loading state, you configure the async rule once in the schema. The form exposes `isValidating` on the form store if you need to show a global loading indicator. ```ts const schema = v.objectAsync({ username: v.pipeAsync( v.string(), v.minLength(3, 'Username must be at least 3 characters.'), v.checkAsync(isUsernameAvailable, 'This username is already taken.') ), password: v.pipe(v.string(), v.minLength(8)), }); ``` For cases where the same username is checked more than once, Valibot's [`cache`](https://valibot.dev/api/cache/) method can wrap the schema to skip re-running the server check for values that have already been validated. The same schema-first pattern applies to cross-field validation, where one field's validity depends on another. In RHF, making two password fields validate against each other requires custom event handling in the component. In Formisch, `partialCheck` defines the comparison in the schema and `forward` directs the error to the right field. Neither component knows the other exists. ```ts const schema = v.pipe( v.object({ password: v.pipe( v.string(), v.minLength(8, 'Your password must have 8 characters or more.') ), confirmPassword: v.string(), }), v.forward( v.partialCheck( [['password'], ['confirmPassword']], (input) => input.password === input.confirmPassword, 'The two passwords do not match.' ), ['confirmPassword'] ) ); ``` The error surfaces on the confirm password field's `field.errors` with no additional wiring in the component. When validation rules change, you update the schema, and the rest of the form reads from that definition. This becomes more noticeable as validation rules evolve or as the form grows in complexity. In RHF, validation logic is either attached to individual fields or wired through a resolver, so changes often involve updating rules in multiple places. In TanStack Form, validation is centralized in configuration, but still lives alongside component code through validators and their triggers. In Formisch, validation lives entirely in the schema. When rules change, you update the schema, and the rest of the form derives from that definition. This also affects how portable your components are. In RHF, field components tend to carry implicit knowledge of their validation rules. In TanStack Form, they are tied to the validators configured for them. In Formisch, field components don’t contain validation logic, they adapt to whatever schema is provided. With Formisch, validation is entirely schema-driven. Components remain simple, but the schema becomes the single place where correctness is defined. | | Where rules live | Timing control | Async handling | | ----------------- | ------------------------------ | ----------------------- | ----------------------------- | | **RHF** | Field registration or resolver | Form-wide `mode` option | Manual loading state | | **TanStack Form** | Per-validator configuration | Per-validator trigger | Built-in `isValidating` state | | **Formisch** | Valibot schema | Schema-level | Built-in, via schema | > Next, we'll look at how each library's approach affects rendering performance as forms grow. #### Performance under complexity ##### React Hook Form [RHF's core design decision is that field values do not live in React state](https://react-hook-form.com/docs). They live in RHF's internal object. RHF updates the native HTML input directly through refs, bypassing React's re-render system entirely. When you type into a field, React does not re-render in response to input changes by default, since values are stored outside React state. There is no state update and no re-render. This is what makes RHF fast. It is a deliberate architectural choice. The tradeoff appears when React needs to know about a field's value. Conditional rendering, UI updates as you type, and cross-field rules all require React to be aware of the current value. The two mechanisms for this are `watch`, which triggers re-renders at the root of the form, and `useWatch`, which isolates re-renders at the component level. Using `watch` broadly is the common pattern and the source of most performance complaints. `useWatch` is the more focused approach but requires deliberate structure to use effectively. In small forms, this is not a problem, but in larger forms with many watched fields, the re-render problem returns, but scoped to the components subscribed to those fields. [`useFieldArray`](https://react-hook-form.com/docs/usefieldarray) makes this more visible. When you append, remove, or reorder rows, RHF updates its internal array state. Components subscribed broadly to `formState`, especially at higher levels, may re-render as a result depending on how subscriptions are structured. RHF provides mitigations such as memoized callbacks, stable field IDs, and selective state subscriptions. However, these optimizations require deliberate structure. Using [`Controller`](https://react-hook-form.com/docs/usecontroller) inside a field array, or subscribing to `formState` in a parent component, can cause full re-renders of the array on each mutation. The common solution is to move [`useFormState`](https://react-hook-form.com/docs/useformstate) subscriptions down to leaf components so updates stay localized. This works, but it means performance depends on how carefully the component tree is structured. For most production forms, this never becomes a problem, the tradeoff only surfaces at scale. RHF avoids re-renders by default, but once values enter React state, controlling re-render scope becomes your responsibility. ##### TanStack Form [TanStack Form stores field values in a reactive store, and components subscribe only to the specific slice of state they use](https://tanstack.com/form/latest/docs/framework/react/guides/reactivity). When a field changes, only components subscribed to that field update. This results in predictable and granular updates without requiring manual optimization of component structure. The store handles subscription scoping automatically. The same behavior applies to field arrays, adding or removing rows updates only the affected components. Unchanged rows and fields do not re-render. This level of granularity does not depend on how the component tree is organized. Note that using a schema as a form-level `onChange` validator can cause all fields to re-render on every keystroke. This is worth keeping in mind if you are combining schema-based validation with form-level validators. TanStack Form's store-based reactivity manages re-render scope automatically, so performance remains stable as forms grow without additional architectural work. ##### Formisch Formisch uses signals as its reactive model internally, but this is an implementation detail you don't interact with directly. The library exposes plain values β€” you read `field.input`, not a signal β€” and the reactivity is handled for you behind the scenes. A signal represents a single value and tracks exactly which components depend on it. When a signal changes, only those components update, allowing updates to be scoped very precisely with minimal overhead compared to store-based approaches. The dependency graph is more direct than a store-based model. In a large field array where many rows update frequently, only the component reading that specific value re-renders. This difference is small in most web forms and more noticeable in React Native or in very large dynamic forms. Formisch is also framework-agnostic. The same mental model works across React, SolidJS, Vue, Svelte, Preact, and Qwik. That is relevant if your team works across multiple framework environments, or expects that to change. For most applications, the difference between TanStack Form and Formisch comes down to this: both scope re-renders automatically without requiring deliberate component structure. Formisch does it at the signal level, which can reduce overhead as form size and update frequency increase. | | State model | Re-render scope | What you manage | | ----------------- | ------------------------------------- | ------------------------------------- | ------------------- | | **RHF** | Uncontrolled (internal object + refs) | Manual via `watch` and `useFormState` | Component structure | | **TanStack Form** | Reactive store | Automatic per subscription | Nothing extra | | **Formisch** | Signals | Automatic per signal | Nothing extra | > With those three areas covered, here is how to make the actual call for your project. #### Which library should you use? **When does Formisch make more sense than TanStack Form?** When TypeScript accuracy and long-term maintainability are the primary concerns, and you want validation logic to live in one place instead of being distributed across component configuration. It also makes more sense for new projects where you are not already invested in the TanStack ecosystem. **When does TanStack Form make more sense than React Hook Form?** When you need fine-grained control over validation timing and built-in async validation handling without building that infrastructure yourself. It also makes more sense if your team is already using TanStack Query or TanStack Router and a consistent mental model across data fetching, routing, and forms has practical value. **Should new projects use React Hook Form?** For simple to moderately complex forms, RHF remains a mature, well-documented, widely understood choice. If you are not expecting significant form complexity, there is no strong reason to choose a different approach. **Should you switch from React Hook Form to TanStack Form or Formisch?** Not necessarily. If your current forms are working and you are not running into the problems described in this post, the switching cost might not be worth it and the benefit is limited. RHF is still a well-maintained library with a large community and years of production usage behind it. TanStack Form and Formisch exist because the problems teams bring to form libraries have changed. Larger codebases, stricter TypeScript requirements, more complex validation logic, and more dynamic field structures. RHF's design decisions still make sense for simpler forms. The tradeoffs become visible as forms grow in size and complexity, especially when validation logic, cross-field dependencies, and TypeScript alignment start to accumulate. If your forms are mostly static, your team already uses RHF, and you are not experiencing TypeScript or validation issues, stay with RHF. The switching cost is unlikely to justify itself. If you are starting a new project in TypeScript and expect forms to grow in complexity, Formisch is worth evaluating. The schema-first approach reduces the number of moving pieces you have to keep aligned over time. If you are already using the TanStack ecosystem and want better validation control than RHF without changing mental models too drastically, TanStack Form is the natural fit. If your current RHF setup is already showing signs of strain such as type drift, validation logic spread across components, or performance issues in large field arrays, then evaluating Formisch or TanStack Form becomes more relevant. Formisch makes the most sense for new projects. Migrating an existing RHF codebase to Formisch is not trivial. The mental model is different enough that this is a rewrite of your form layer, not a drop-in replacement. That is not a reason to avoid it, but it is a reason to be clear about the cost before committing to it. | | Best for | Main consideration | | ------------- | ---------------------------------------- | ------------------------------------------ | | Formisch | New projects, TypeScript-heavy codebases | Valibot only. Migration requires a rewrite | | TanStack Form | Complex forms, TanStack ecosystem | More explicit configuration | | RHF | Simple to moderately complex forms | No switching cost if already in use | #### Migrating from React Hook Form to Formisch Migrating from RHF to Formisch is not a drop-in replacement, and the mental models are different enough that this is a genuine rewrite of your form layer. The scope of the work is worth understanding before starting. The core change is that the schema becomes the starting point for everything. In RHF, you start with a component and attach form behavior to it. In Formisch, you start with a schema and build the component around it. ##### What changes: - `useForm()` becomes `useForm({ schema: MySchema })`. The TypeScript type is inferred from the schema. - `register("fieldName")` becomes a [``](https://formisch.dev/react/api/Field/) component that exposes `field.props`, `field.input`, and `field.errors`. - Schema resolvers are no longer needed. The schema is passed directly to `useForm`. - `formState.errors.field` becomes `field.errors`. - `watch("field")` is replaced by [`getInput(form, { path: ["field"] })`](https://formisch.dev/methods/api/getInput/) when you need access outside the field component. - `useFieldArray` is replaced by Formisch's [`useFieldArray`](https://formisch.dev/react/api/useFieldArray) hook or [`FieldArray`](https://formisch.dev/react/api/FieldArray/) component used alongside `insert`, `remove`, `move`, `swap`, and `replace` methods on the form. Your input components, submit handlers, and server-side validation logic remain mostly the same. Formisch is headless and works with your existing UI components. Migrate one form at a time instead of rewriting everything at once. Start with the most complex form, where the limitations of RHF are most visible. This gives you a clear comparison and a reusable pattern for the rest of your codebase. Formisch and RHF can coexist in the same application without conflict. > **Note:** You will need to add Valibot (`valibot`) alongside `@formisch/react`. If you are currently using Zod, porting schemas to Valibot is a separate step. The concepts are similar, but the APIs differ. | | RHF | Formisch | | ------------------- | ------------------------ | ------------------------------------------------------------------------------- | | Form initialization | `useForm()` | `useForm({ schema })` | | Field registration | `register("field")` | `` | | Schema connection | Resolver configuration | Direct schema | | Error access | `formState.errors.field` | `field.errors` | | Read value | `watch("field")` | `getInput(form, { path: ["field"] })` | | Dynamic arrays | `useFieldArray` | `useFieldArray` or `FieldArray` + `insert`, `remove`, `move`, `swap`, `replace` | It's less about features and more about where you want the complexity to live. Which one makes sense depends on how much complexity your forms actually carry.