# Architecture

> This document is the Markdown version of [formisch.dev/angular/guides/architecture/](https://formisch.dev/angular/guides/architecture/). For the complete documentation index, see [llms.txt](https://formisch.dev/llms.txt).

You don't need to read this guide to use Formisch. It's here for people who want to understand why the bundle stays small, why updates are fine-grained, and how the same library can support Angular, Vue, SolidJS, React, Svelte, 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/core`](https://github.com/open-circle/formisch/tree/main/packages/core) is the framework-agnostic foundation. It contains the form and field store types, the recursive store builder that mirrors your Valibot schema, and the validation orchestration.
- [`@formisch/methods`](https://github.com/open-circle/formisch/tree/main/packages/methods) exposes the form operations as standalone, tree-shakeable functions: [`setInput`](/methods/api/setInput.md), [`validate`](/methods/api/validate.md), [`reset`](/methods/api/reset.md), [`insert`](/methods/api/insert.md), [`move`](/methods/api/move.md), [`focus`](/methods/api/focus.md) and so on. Each method lives in its own file and only imports the core helpers it actually needs.
- [`@formisch/angular`](https://github.com/open-circle/formisch/tree/main/frameworks/angular) is the thin user-facing layer. It provides the [`injectForm`](/angular/api/injectForm.md), [`injectField`](/angular/api/injectField.md) and [`injectFieldArray`](/angular/api/injectFieldArray.md) functions, the [`[formischForm]`](/angular/api/formischForm.md), [`*formischField`](/angular/api/formischField.md), [`*formischFieldArray`](/angular/api/formischFieldArray.md) and [`[formischControl]`](/angular/api/formischControl.md) directives, and re-exports every method from `@formisch/methods` so you only import from a single package.

```
@formisch/angular
  ├─ injectForm, [formischForm], *formischField, …   (Angular-specific layer)
  └─ re-exports @formisch/methods/angular            (focus, reset, validate, …)
         └─ @formisch/core/angular                   (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 `injectForm`, `[formischForm]`, `*formischField` and `[formischControl]` ships nothing else, even though `@formisch/angular` 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/angular`, `@formisch/core/vue`, `@formisch/core/solid`, …), all built from the same source. The only file that differs between them [is a small adapter](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.ts) that exports four functions: `createSignal`, `batch`, `untrack` and `createId`.

The swap happens at build time, not at runtime. A small [rolldown plugin](https://github.com/open-circle/formisch/blob/main/packages/core/tsdown.config.ts) sits in front of import resolution: whenever the bundler resolves an import of `./framework/index.ts`, the plugin looks for a sibling `./framework/index.angular.ts` (or `.vue.ts`, `.solid.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.

[The Angular adapter](https://github.com/open-circle/formisch/blob/main/packages/core/src/framework/index.angular.ts) wraps Angular's own `signal` in the `.value` getter/setter shape Formisch's core relies on, and maps `untrack` onto Angular's `untracked`:

```ts
import { signal } from '@angular/core';

export { untracked as untrack } from '@angular/core';

export function createSignal<T>(initialValue: T): Signal<T> {
  const writableSignal = signal(initialValue);
  return {
    get value() {
      return writableSignal();
    },
    set value(nextValue: T) {
      writableSignal.set(nextValue);
    },
  };
}
```

`batch` is a no-op for Angular, since Angular's scheduler already coalesces signal writes into a single change detection pass.

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 `Signal<T>` 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`](https://github.com/open-circle/formisch/blob/main/packages/core/src/form/createFormStore/createFormStore.ts) 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 Angular signals, fully integrated with Angular's dependency tracking, `OnPush` change detection and zoneless applications.

That Formisch is framework-agnostic is not a trade-off you make. It's a benefit you get. A bug surfaced by a Svelte user is a bug fixed for Angular, Vue, Solid, React 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>`](https://github.com/open-circle/formisch/blob/main/packages/core/src/types/signal/signal.ts) with the same minimal interface:

```ts
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`, `isEdited`, `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 parts of your template that read that one signal (typically a single `<input />`) update.

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 `injectForm`

When you call [`injectForm`](/angular/api/injectForm.md), three layers cooperate to produce the form store you receive.

```ts
readonly loginForm = injectForm({ schema: LoginSchema });
```

First, the [Angular wrapper](https://github.com/open-circle/formisch/blob/main/frameworks/angular/src/functions/injectForm/injectForm.ts) hands the configuration to the core function [`createFormStore`](https://github.com/open-circle/formisch/blob/main/packages/core/src/form/createFormStore/createFormStore.ts), together with a parse closure that captures your schema:

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

Next, `createFormStore` calls [`initializeFieldStore`](https://github.com/open-circle/formisch/blob/main/packages/core/src/field/initializeFieldStore/initializeFieldStore.ts), 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 four signals are created: `errors`, `isTouched`, `isEdited` and `isDirty`. Once the field tree is built, three more signals are added to the form root: `isSubmitting`, `isSubmitted` and `isValidating`.

Finally, the Angular wrapper builds the public form store you actually receive. Every property is an Angular `computed` that reads the underlying core signals, which is why you call them as functions in your template and why they participate in Angular's dependency tracking:

```ts
return {
  [INTERNAL]: internalFormStore,
  isSubmitting: computed(() => internalFormStore.isSubmitting.value),
  isTouched: computed(() => getFieldBool(internalFormStore, 'isTouched')),
  isDirty: computed(() => getFieldBool(internalFormStore, 'isDirty')),
  isValid: computed(() => !getFieldBool(internalFormStore, 'errors')),
  // …
};
```

The internal store is tucked away behind the [`INTERNAL`](https://github.com/open-circle/formisch/blob/main/packages/core/src/values.ts) 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.

## How the directives connect to the DOM

The Angular package adds one layer the other bindings don't need: the [`[formischControl]`](/angular/api/formischControl.md) directive, which owns the element.

It registers the host element with the field inside an `effect`, so that when a field array is reordered and the resolved field store changes, the element is re-registered on the correct store. It writes the field input into the element inside an `afterRenderEffect` write phase, which guarantees that sibling DOM — such as the options of a `<select />` — already exists when the value is applied. And because a `<select />` may receive its options later still, the directive observes option mutations and re-applies the value when they arrive.

## Why this design pays off

Bundle size is the obvious payoff. The others are why the design is worth talking about:

- **Small bundle size.** Because methods are individual modules re-exported from [`@formisch/angular`](https://github.com/open-circle/formisch/tree/main/frameworks/angular), a form that uses only [`injectForm`](/angular/api/injectForm.md) and the field directives never pulls in [`validate`](/methods/api/validate.md), [`reset`](/methods/api/reset.md), [`insert`](/methods/api/insert.md) 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 Angular's reactive graph handles propagation. Updating one field does not invalidate the rest, and no `NgZone` is involved.
- **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 — including the typed template contexts of the structural directives — 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](/angular/guides/comparison.md) guide. For a closer look at how Valibot's inference flows into your editor, see [TypeScript](/angular/guides/typescript.md). And if you want to read the recursive walker, the adapter implementations, or how individual methods are written, the full source is on [GitHub](https://github.com/open-circle/formisch).
