Architecture

You don't need to read this guide to use Formisch. It's here for the curious — for people who want to understand why the bundle stays small, why updates are fine-grained, and how the same library can support Svelte, SolidJS, React, Vue, Preact and Qwik without forking the codebase. Each of those properties falls out of a few deliberate architectural choices.

Three packages, three responsibilities

Formisch is structured as three packages, each with a clear role:

@formisch/svelte
  ├─ createForm, <Form />, <Field />, …          (Svelte-specific reactive layer)
  └─ re-exports @formisch/methods/svelte         (focus, reset, validate, …)
         └─ @formisch/core/svelte                (createFormStore, types, signals)
                  └─ framework adapter           (createSignal, batch, untrack, createId)

The modular architecture is what makes Formisch tree-shakeable. The core stays small, and every additional capability — setInput, validate, reset, insert, move, focus and the rest — is exported as its own function. A form that only imports createForm, <Form /> and <Field /> ships nothing else, even though @formisch/svelte re-exports every method.

One core, one adapter per framework

The core package is framework-agnostic, but it still needs some reactivity primitive to build on. Formisch handles this by providing a subpath export for every supported framework (@formisch/core/svelte, @formisch/core/solid, @formisch/core/react, …), all built from the same source. The only file that differs between them is a small adapter that exports four functions: createSignal, batch, untrack and createId.

The swap happens at build time, not at runtime. A small rolldown plugin sits in front of import resolution: whenever the bundler resolves an import of ./framework/index.ts, the plugin looks for a sibling ./framework/index.svelte.ts (or .solid.ts, .react.ts, …) and, if it exists, redirects the import there. The build runs once per framework, and each run produces a self-contained bundle with the framework-specific adapter inlined as if it had always been the only option.

For Svelte, the adapter is a thin wrapper around $state.raw that exposes the Signal<T> shape Formisch's core relies on:

export function createSignal<T>(initialValue: T): Signal<T> {
  let signal = $state.raw(initialValue);
  return {
    get value() {
      return signal;
    },
    set value(nextValue: T) {
      signal = nextValue;
    },
  };
}

For frameworks that don't have a native signal primitive (like React), the adapter implements a small pub/sub system from scratch that exposes the same shape. Every other file in @formisch/core only ever imports createSignal, batch, untrack and createId, never a framework directly. That's why the same createFormStore code path can power every framework binding without a single conditional.

This is what makes Formisch different from most framework-agnostic libraries. The usual approach is to ship a runtime abstraction — a custom store, an observer protocol, a subscription layer — that lives in the bundle and has to be bridged into each framework. Formisch ships nothing like that. Where the framework has native signals, Formisch uses them directly; where it doesn't, the adapter is just enough pub/sub to drive re-renders. Either way, there is no extra layer between your form state and the framework's reactivity, and your bundle pays no overhead for portability. Form state lives in real $state.raw cells, integrated with Svelte's compiler-driven fine-grained tracking.

That Formisch is framework-agnostic is not a trade-off you make — it's a benefit you get. A bug surfaced by a Solid user is a bug fixed for Svelte, React, Vue and everyone else. An improvement to the recursive store builder, the validation orchestration or any individual method lands across every binding at once. There is one library to maintain, not one per framework, and every community pulls the rest forward.

State lives in signals

Every piece of reactive state in Formisch is a Signal<T> with the same minimal interface:

interface Signal<T> {
  get value(): T;
  set value(nextValue: T): void;
}

Reads track, writes notify. There is no central store object that gets diffed and no equality check on the consumer side. Each field carries its own signals for errors, isTouched, isDirty, plus an input signal for value fields and a children collection for arrays and objects. When you call setInput(form, { path: ['email'], input: 'a@b.c' }), only the input signal of that one field is written, so only the components that read that one signal — typically a single <input /> — re-render.

This is the source of the fine-grained reactivity. The shape of the store is pre-allocated at form creation, matching the shape of your Valibot schema, so methods never have to look up paths in a generic object — they walk a typed tree of stores and update individual signals.

What happens when you call createForm

When you call createForm, three layers cooperate to produce the form store you receive.

<script lang="ts">
  import { createForm } from '@formisch/svelte';
  import * as v from 'valibot';

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

  const loginForm = createForm({ schema: LoginSchema });
</script>

First, the Svelte wrapper hands the configuration to the core function createFormStore, together with a parse closure that captures your schema:

const internalFormStore = createFormStore(config, (input) =>
  v.safeParseAsync(config.schema, input)
);

Next, createFormStore calls initializeFieldStore, which walks your Valibot schema recursively and builds the internal store hierarchy:

  • A v.object(...) becomes an InternalObjectStore with a children record keyed by field name.
  • A v.array(...) or v.tuple(...) becomes an InternalArrayStore with a children list and its own initialInput, startInput and input signals so it can track structural changes.
  • A leaf like v.string() or v.number() becomes an InternalValueStore with an input signal that holds the current field value.
  • Wrapper schemas such as v.optional, v.nullable, v.nonNullable and v.lazy are unwrapped and recursed into.
  • v.union, v.variant and v.intersect initialize every option into the same store, which is what lets you address discriminated fields with a single field path.

At every level, the same three signals are created: errors, isTouched and isDirty. Once the field tree is built, three more signals are added to the form root: isSubmitting, isSubmitted and isValidating.

Finally, the Svelte wrapper builds the public form store you actually receive. It's a small reactive object that sits on top of the internal store: form-level signals are exposed through getters that read internalFormStore.isSubmitting.value and friends, while derived state like isTouched, isDirty and isValid is declared with $derived so it recomputes lazily as the underlying field signals change:

const isTouched = $derived(getFieldBool(internalFormStore, 'isTouched'));
const isDirty = $derived(getFieldBool(internalFormStore, 'isDirty'));
const isValid = $derived(!getFieldBool(internalFormStore, 'errors'));

return {
  [INTERNAL]: internalFormStore,
  get isSubmitting() {
    return internalFormStore.isSubmitting.value;
  },
  get isTouched() {
    return isTouched;
  },
  // …
};

The internal store is tucked away behind the INTERNAL symbol. Methods like setInput(form, …) reach in via form[INTERNAL] to mutate signals, while your code only sees the public form store. This separation is what keeps the public API small and stable even as the internal shape evolves.

Why this design pays off

The same four properties keep coming back, and now you can see where they come from:

  • Small bundle size. Because methods are individual modules re-exported from @formisch/svelte, a form that uses only createForm and <Field /> never pulls in validate, reset, insert or the other operations. Tree-shaking gets to do real work.
  • Fine-grained reactivity. The store shape is pre-allocated at form creation, with one signal per piece of state. Methods write .value directly and Svelte's compiler-driven tracking handles propagation. Updating one field does not invalidate the rest.
  • Framework portability. The entire library is parameterized over a four-function adapter. Adding a new framework means writing a small adapter that maps createSignal, batch, untrack and createId onto its primitives.
  • Type safety. Your Valibot schema is the single source of truth. It drives both runtime parsing and the inferred TypeScript types for paths, inputs and outputs, so there is no second declaration to keep in sync.

Further reading

To see how these design choices stack up against alternatives, head to the comparison guide. For a closer look at how Valibot's inference flows into your editor, see TypeScript. And if you want to read the recursive walker, the adapter implementations, or how individual methods are written, the full source is on GitHub.

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 @stefanmaric
  • 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 @t-lander
  • GitHub profile picture of @dslatkin