# Formisch
> The lightweight, schema-first and fully type-safe form library for Angular, Preact, Qwik, React, Solid, Svelte and Vue.
## Get started
### Introduction
Formisch is a schema-based, headless form library for Angular. It manages form state and validation. It is type-safe, fast by default and its bundle size is small due to its modular design. Try it out in our [playground](/playground/login/)!
#### Highlights
- Small bundle size due to its modular design
- Schema-based validation with Valibot
- Type safety with autocompletion in editor, including fully typed templates
- Open source and fully tested with 100 % coverage
- It's fast – fine-grained updates powered by Angular Signals
- Works with zoneless change detection and `OnPush` components
- Minimal, readable and well thought out API
- Supports all native HTML form fields
#### Example
Every form starts with the [`injectForm`](/angular/api/injectForm.md) function. It initializes your form's store based on the provided Valibot schema and infers its types. Next, add the [`[formischForm]`](/angular/api/formischForm.md) directive to your native `
`,
})
export class LoginComponent {
readonly loginForm = injectForm({ schema: LoginSchema });
readonly handleSubmit = (output: v.InferOutput) => {
console.log(output);
};
}
```
In addition, Formisch offers several functions (we call them "methods") that can be used to read and manipulate the form state. These include [`focus`](/methods/api/focus.md), [`getDeepErrors`](/methods/api/getDeepErrors.md), [`getErrors`](/methods/api/getErrors.md), [`getInput`](/methods/api/getInput.md), [`handleSubmit`](/methods/api/handleSubmit.md), [`insert`](/methods/api/insert.md), [`move`](/methods/api/move.md), [`remove`](/methods/api/remove.md), [`replace`](/methods/api/replace.md), [`reset`](/methods/api/reset.md), [`setErrors`](/methods/api/setErrors.md), [`setInput`](/methods/api/setInput.md), [`submit`](/methods/api/submit.md), [`swap`](/methods/api/swap.md) and [`validate`](/methods/api/validate.md). These methods allow you to control the form programmatically.
#### Coming from Angular forms
Formisch maps naturally onto what you already know from Reactive Forms and Signal Forms:
| Reactive Forms | Signal Forms | Formisch |
| ---------------------- | ------------------------ | ------------------------------------ |
| `[formGroup]="form"` | – | `[formischForm]="form"` |
| `[formControl]="ctrl"` | `[formField]="f.email"` | `[formischControl]="field"` |
| `(ngSubmit)="save()"` | `submit(form, action)` | `[formischSubmit]="handleSubmit"` |
| `new FormControl('')` | `form(model).email` | `injectField(form, { path: [...] })` |
| `form.valid` | `form().valid()` | `form.isValid()` |
| `Validators.required` | schema fn / Zod, Valibot | Valibot schema |
Like Reactive Forms' `[formControl]` and Signal Forms' `[formField]`, the [`[formischControl]`](/angular/api/formischControl.md) directive writes the field state into the element – there is no manual `[value]` or `[checked]` binding. Validation lives in one place, your Valibot schema, from which all input, output and path types are inferred.
For a step-by-step migration with a side-by-side example, see the [migrate from Reactive Forms](/angular/guides/migrate-from-reactive-forms.md) guide.
#### Comparison
What makes Formisch unique is its framework-agnostic core, which is fully native to the framework you are using. It works by inserting framework-specific reactivity blocks when the core package is built, giving you native performance for any UI update. A modular methods API keeps bundles small by only including the methods you import, and end-to-end type safety covers deeply nested paths and field arrays with TypeScript inference that stays fast even as forms grow.
For a side-by-side look at how Formisch compares to Reactive Forms, Signal Forms, ngx-formly, and TanStack Form, see the [comparison guide](/angular/guides/comparison.md).
#### Vision
My vision for Formisch is to create a framework-agnostic platform similar to [Vite](https://vite.dev/), but for forms — a shared core that lets the same mental model and codebase work natively across every modern UI framework.
#### Feedback
Find a bug or have an idea how to improve the library? Please fill out an [issue](https://github.com/open-circle/formisch/issues/new). Together we can make forms even better!
#### License
This project is available free of charge and licensed under the [MIT license](https://github.com/open-circle/formisch/blob/main/LICENSE.md).
### Installation
Below you will learn how to add Formisch to your project.
#### TypeScript
If you are using TypeScript, we recommend that you enable strict mode in your `tsconfig.json` so that all types are calculated correctly.
> The minimum required TypeScript version is v5.0.2.
```ts
{
"compilerOptions": {
"strict": true,
// ...
}
}
```
We also recommend enabling `strictTemplates` in the `angularCompilerOptions` of your `tsconfig.json`. Formisch types the template context of its structural directives, so with strict templates an invalid field path or a wrong field type is reported directly in your template.
```ts
{
"angularCompilerOptions": {
"strictTemplates": true,
// ...
}
}
```
#### Install Valibot
Formisch uses [Valibot](https://valibot.dev/) for schema-based validation. You need to install it first because it is a peer dependency.
```bash
npm install valibot # npm
yarn add valibot # yarn
pnpm add valibot # pnpm
bun add valibot # bun
deno add npm:valibot # deno
```
#### Install Formisch
You can add Formisch to your project with a single command using your favorite package manager.
```bash
npm install @formisch/angular # npm
yarn add @formisch/angular # yarn
pnpm add @formisch/angular # pnpm
bun add @formisch/angular # bun
deno add npm:@formisch/angular # deno
```
Then you can import it into any JavaScript or TypeScript file.
```ts
import { … } from '@formisch/angular';
```
Formisch ships standalone directives. Add the ones you use, such as `FormischForm` and `FormischField`, to the `imports` array of your component — there is no `NgModule` to import.
> Formisch requires Angular v19 or later, since it is built on top of signals, `input.required` and `afterRenderEffect`.
#### For AI Agents
We provide agent skills that teach AI agents the correct patterns for working with Valibot and Formisch. You can install them by running the following command in your terminal:
```bash
npx skills add open-circle/agent-skills --skill formisch valibot
```
You can learn more about the Valibot and Formisch agent skill [here](https://github.com/open-circle/agent-skills).
### Coding agents
Formisch is built to be consumed by AI coding agents. This page describes the agent skill, MCP server and Markdown documentation we provide so that your AI tools generate correct Angular code with Formisch.
#### Agent skill
Formisch defines form values and validation with [Valibot](https://valibot.dev/) schemas, so an agent needs two skills: our [Formisch SKILL.md](https://github.com/open-circle/agent-skills/blob/main/skills/formisch/SKILL.md) for building forms and managing form state, and the [Valibot SKILL.md](https://github.com/open-circle/agent-skills/blob/main/skills/valibot/SKILL.md) for writing the schemas. Install both with the [Agent Skills CLI](https://agentskills.io/):
```bash
npx skills add open-circle/agent-skills --skill formisch valibot
```
The Formisch skill is also published at [`/.well-known/agent-skills/formisch/SKILL.md`](/.well-known/agent-skills/formisch/SKILL.md) and listed in our [discovery index](/.well-known/agent-skills/index.json). For Valibot's MCP server and llms.txt files, see [Valibot's coding agents guide](https://valibot.dev/guides/coding-agents/).
#### MCP server
We host a [Model Context Protocol](https://modelcontextprotocol.io/) server at `https://formisch.dev/mcp` that gives coding agents first-class tools to search and read the documentation instead of crawling pages. It is free and requires no authentication.
The server provides three tools:
- `search_docs` searches the documentation and returns the most relevant pages
- `get_doc` reads a documentation page or blog post as Markdown
- `list_docs` lists all pages grouped by framework and area
Add it to Claude Code with:
```bash
claude mcp add --transport http formisch https://formisch.dev/mcp
```
Or add it to the MCP configuration of your tool:
```json
{
"mcpServers": {
"formisch": {
"url": "https://formisch.dev/mcp"
}
}
}
```
The server metadata is available at [`/.well-known/mcp/server-card.json`](/.well-known/mcp/server-card.json).
#### LLMs.txt
We provide several [LLMs.txt](https://llmstxt.org/) files. Use the one that works best with your AI tool.
- [`llms.txt`](/llms.txt) contains a table of contents with links to all Markdown files
- [`llms-angular.txt`](/llms-angular.txt) contains a table of contents with links to Angular-related files
- [`llms-angular-full.txt`](/llms-angular-full.txt) contains the Markdown content of the entire Angular docs
- [`llms-angular-guides.txt`](/llms-angular-guides.txt) contains the Markdown content of the Angular guides
- [`llms-angular-api.txt`](/llms-angular-api.txt) contains the Markdown content of the Angular API reference
- [`llms-blog.txt`](/llms-blog.txt) contains the Markdown content of all blog posts
#### Markdown for agents
Every documentation page is available as Markdown. Append `.md` to the URL of a page (e.g. `/angular/guides/installation/` becomes [`/angular/guides/installation.md`](/angular/guides/installation.md)) or request the page with an `Accept: text/markdown` header to receive its Markdown version.
### Comparison
Formisch is one of several form libraries available for Angular. The most common alternatives are Angular's built-in [Reactive Forms](https://angular.dev/guide/forms/reactive-forms), the newer [Signal Forms](https://angular.dev/guide/forms/signals), [ngx-formly](https://formly.dev), and [TanStack Form](https://tanstack.com/form/latest). This page is meant as a quick reference for picking the right tool.
#### At a glance
| | **Formisch** | Reactive Forms | Signal Forms | TanStack Form |
| ---------------------- | ------------------------------------------------ | ------------------------------- | ---------------------------------- | --------------------------------------- |
| Type source | Inferred from schema | Declared manually | Inferred from model signal | Inferred from `defaultValues` |
| Validation location | Defined in schema | Validator functions per control | Schema function or Standard Schema | Per-validator config |
| Validation timing | Form-wide `validate` / `revalidate` | Per control (`updateOn`) | Continuous, signal-driven | Per-validator trigger |
| Async validation | Built-in via schema | Async validators | Built-in (`validateAsync`) | Built-in `isValidating` |
| Reactivity scope | Per signal subscription | RxJS observables | Per signal subscription | Per TanStack Store subscription |
| Schema libraries | Valibot | – | Standard Schema | Standard Schema |
| UI approach | Headless | Headless | Headless | Headless |
| Bundle size (min+gzip) | From ~4.5 kB | Bundled with Angular | Bundled with Angular | ~15 kB |
| Framework support | Angular, React, Preact, Solid, Svelte, Vue, Qwik | Angular | Angular | React, Vue, Solid, Svelte, Lit, Angular |
The table is intentionally short. It only covers the dimensions that most often drive a library choice in practice. Other differences such as devtools, ecosystem maturity, and community size are real but tend to matter less than how each library handles types, validation, and reactivity.
Note that Signal Forms is Angular's newer, signal-based forms API. Check the [Angular documentation](https://angular.dev/guide/forms/signals) for its current stability status before adopting it in production.
#### Why Formisch?
Three reasons to pick Formisch over the alternatives above:
**One schema, no second source of truth.** A single Valibot schema is everything the form needs: the runtime validator, the source of types, and the description of the form's structure — all at once. There is no `FormGroup` shape to declare next to an interface, no `Validators` list to keep aligned with your model, no resolver to configure. When the schema changes, every part of the form follows — at compile time and at runtime.
**A small, tree-shakable bundle.** A form with the directives and `injectForm` starts at ~4.5 kB min+gzip and grows only as you import additional methods like `focus`, `getInput`, and `reset` — methods you don't import don't end up in your bundle. Angular's built-in form libraries ship with the framework, but compared to other third-party form libraries Formisch is several times smaller.
**Type safety that stays fast.** Types flow from the schema through every API, including deeply nested paths and field arrays — and through your templates, since the structural directives declare a typed template context. With `strictTemplates` enabled, an invalid path or a mistyped field is a compile error. The inference is structured to keep TypeScript editor performance from degrading as schemas grow.
#### Which library should you use?
**Reactive Forms** is the battle-tested default that ships with Angular. It has the largest ecosystem, works in every Angular version, and integrates with everything. The trade-offs are that types are declared by hand rather than inferred, validation logic is spread across `Validators` on individual controls, and its state is RxJS-based rather than signal-based.
**Signal Forms** is Angular's signal-native successor to Reactive Forms, deriving a form from a model signal and supporting Standard Schema validation. It's a strong fit if you want to stay entirely within the framework and are comfortable adopting a newer API.
**ngx-formly** is schema-driven in a different sense: you describe your fields as JSON-like configuration and it renders the UI for you, with adapters for Material, PrimeNG, and others. Best when your forms are generated dynamically from server-side definitions rather than written by hand.
**TanStack Form** is a good fit when you need fine-grained control over validation timing and built-in async validation handling without building that infrastructure yourself. It is also the natural choice if your team already uses TanStack libraries and values a consistent mental model across data fetching, routing, and forms.
**Formisch** makes the most sense for new projects in TypeScript-heavy codebases, especially when you expect forms to grow in complexity, and when the same team also writes forms for other frameworks and wants one mental model across all of them. The schema-first design means there is a single source of truth for types, runtime validation, and form structure. Reactivity is fine-grained through signals, so it works naturally with `OnPush` and zoneless change detection. The main consideration is that Formisch currently supports only [Valibot](https://valibot.dev) as the schema library.
#### Migrating to Formisch
Migrating to Formisch is not a drop-in replacement, but the libraries can coexist in the same application, so you can migrate one form at a time. The main work is consolidating validation rules into a single root Valibot schema and replacing the previous library's form and field APIs with Formisch's functions and directives.
We provide a dedicated migration guide for each library, with a side-by-side example, step-by-step instructions, and an API mapping table: [migrate from Reactive Forms](/angular/guides/migrate-from-reactive-forms.md) and [migrate from TanStack Form](/angular/guides/migrate-from-tanstack-form.md).
#### Next steps
If you have decided that Formisch is a good fit, install it via the [installation](/angular/guides/installation.md) guide and start building by [defining your form](/angular/guides/define-your-form.md).
## Main concepts
### Define your form
Creating a form in Formisch starts with defining a Valibot schema. The schema serves as the blueprint for your form, outlining the structure, data types, and validation rules for each field.
#### Schema definition
Formisch is a schema-first form library built on top of [Valibot](https://valibot.dev/). When you create a form with [`injectForm`](/angular/api/injectForm.md), TypeScript types are automatically inferred from your schema, giving you full autocompletion and type safety throughout your form — including in your templates — without needing to write any manual type definitions.
##### Example schema
The following schema defines a form with two required string fields. The `email` field must be a valid email format, and the `password` field must be at least 8 characters long. Each validation includes custom error messages that will be displayed when validation fails.
> For more complex schema examples, check out the schemas of our [playground](/playground/login/).
```ts
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email address is badly formatted.')
),
password: v.pipe(
v.string(),
v.nonEmpty('Please enter your password.'),
v.minLength(8, 'Your password must have 8 characters or more.')
),
});
```
Schemas are plain values, so define them outside of your component class. This keeps them out of the component's lifecycle and lets you share them with your backend or other components.
##### Field order
The order in which you define fields in your schema matters. When a form is submitted with invalid values, Formisch focuses the first field with an error. Since this follows your schema, define your fields in the same order they appear in your form, so the focus moves to the first invalid field instead of one further down the page.
#### Schema validation
Your schema definition should reflect exactly the data you expect when submitting the form. For example, if the value of a field is optional and will only be submitted in certain cases, your schema should reflect this information by using `v.optional(…)`.
```ts
import * as v from 'valibot';
const ProfileSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty()),
bio: v.optional(v.string()), // <- Optional field
});
```
Formisch validates your form values against the schema before submission, ensuring that your form can only be submitted if it matches your schema definition.
#### Next steps
Now that you understand how to define your form schema, continue to the [create your form](/angular/guides/create-your-form.md) guide to learn how to initialize your form with [`injectForm`](/angular/api/injectForm.md).
### Create your form
Formisch consists of functions, directives and methods. To create a form you use the [`injectForm`](/angular/api/injectForm.md) function.
#### Form function
The [`injectForm`](/angular/api/injectForm.md) function initializes and returns the store of your form. The store contains the state of the form and can be used with other Formisch functions, directives and methods to build your form.
Like Angular's own `inject`, it must be called in an injection context — a field initializer or the constructor of your component.
```ts
import { Component } from '@angular/core';
import { FormischForm, injectForm } from '@formisch/angular';
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
@Component({
selector: 'app-login',
imports: [FormischForm],
template: `
`,
})
export class LoginComponent {
readonly loginForm = injectForm({ schema: LoginSchema });
readonly handleSubmit = (output: v.InferOutput) => {
console.log(output);
};
}
```
The [`[formischForm]`](/angular/api/formischForm.md) directive is a thin layer around the native `
`,
})
export class AuthComponent {
readonly loginForm = injectForm({ schema: LoginSchema });
readonly registerForm = injectForm({ schema: RegisterSchema });
readonly handleLogin = (output: v.InferOutput) => {
console.log(output);
};
readonly handleRegister = (output: v.InferOutput) => {
console.log(output);
};
}
```
If you need a multi-step form (wizard), see the [multi-step form discussion](https://github.com/open-circle/formisch/discussions/108) for patterns and approaches.
#### Next steps
Now that you know how to create a form, continue to the [add form fields](/angular/guides/add-form-fields.md) guide to learn how to connect your input elements to the form using the [`*formischField`](/angular/api/formischField.md) directive.
### Add form fields
To add a field to your form, you can use the [`*formischField`](/angular/api/formischField.md) directive or the [`injectField`](/angular/api/injectField.md) function. Both are headless and provide access to field state for building your form UI.
#### Field directive
The [`*formischField`](/angular/api/formischField.md) directive is a structural directive. It takes the path to the field, the form store after the `of` keyword, and exposes the field store as the implicit template context. If you use TypeScript, you get full autocompletion for the path based on your schema.
```ts
import { Component } from '@angular/core';
import {
FormischControl,
FormischField,
FormischForm,
injectForm,
} from '@formisch/angular';
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
@Component({
selector: 'app-login',
imports: [FormischForm, FormischField, FormischControl],
template: `
`,
})
export class LoginComponent {
readonly loginForm = injectForm({ schema: LoginSchema });
readonly handleSubmit = (output: v.InferOutput) => {
console.log(output);
};
}
```
##### Control directive
The [`[formischControl]`](/angular/api/formischControl.md) directive connects the field store to a native form control. It writes the field input into the element, sets the `name` and `aria-invalid` attributes, registers the element so features like focusing the first invalid field on submit work, and wires the `input`, `change`, `focus` and `blur` handlers.
Because the directive writes to the element, you must not bind `[value]` or `[checked]` to hold the field's state yourself. The exception is a radio or checkbox group, where the `value` of each element identifies its option rather than the state of the field — see the [special inputs](/angular/guides/special-inputs.md) guide.
When a field spans more than one element — a label, the input and an error message — use an `` as the host of the structural directive:
```ts
template: `
@if (field.errors(); as errors) {
{{ errors[0] }}
}
`;
```
##### Headless design
The [`*formischField`](/angular/api/formischField.md) directive does not render its own UI elements. It is headless and provides only the data layer of the field. This allows you to freely define your user interface. You can use HTML elements, custom components or an external UI library.
##### Path array
The path accepts an array of strings and numbers that represents the path to the field in your schema. For top-level fields, it's simply the field name wrapped in an array:
```angular-html
```
For nested fields, the path reflects the structure of your schema:
```angular-html
```
##### Type safety
The API design of the [`*formischField`](/angular/api/formischField.md) directive results in a fully type-safe form. The directive declares a typed template context via `ngTemplateContextGuard`, so with `strictTemplates` enabled TypeScript will immediately alert you if the path is invalid — directly in your template. The field state is also fully typed based on your schema, giving you autocompletion for signals like `field.input()`.
#### injectField function
For very complex forms where you create individual components for each form field, Formisch provides the [`injectField`](/angular/api/injectField.md) function. It allows you to access the field state directly within your component class. Like [`injectForm`](/angular/api/injectForm.md), it must be called in an injection context.
```ts
import { Component, input } from '@angular/core';
import {
FormischControl,
type FormStore,
injectField,
} from '@formisch/angular';
import * as v from 'valibot';
const EmailSchema = v.object({ email: v.pipe(v.string(), v.email()) });
@Component({
selector: 'app-email-input',
imports: [FormischControl],
template: `
@if (field.errors(); as errors) {
{{ errors[0] }}
}
`,
})
export class EmailInputComponent {
readonly form = input.required>();
protected readonly field = injectField(this.form, { path: ['email'] });
}
```
Both the form and the path can be passed as a signal, so the field store follows the input signal of your component or a path that changes over time.
##### When to use which
- **Use the `*formischField` directive**: When defining multiple fields in the same component. It ensures you don't accidentally access the wrong field store.
- **Use the `injectField` function**: When creating field components for single fields. It allows you to access field state in your component class.
The [`*formischField`](/angular/api/formischField.md) directive is essentially a thin wrapper around [`injectField`](/angular/api/injectField.md) that allows you to access the field state within template code.
#### Field store
The field store provides access to the following properties:
- `path`: The path to the field within the form.
- `name`: A signal with the name of the field element (the JSON-stringified path).
- `input`: A signal with the current input value of the field.
- `errors`: A signal with an array of error messages if validation fails, otherwise `null`.
- `isTouched`: A signal with whether the field has been touched.
- `isEdited`: A signal with whether the field's value has been changed.
- `isDirty`: A signal with whether the current input differs from the initial input.
- `isValid`: A signal with whether the field passes all validation rules.
- `setInput`: Sets the field input value programmatically.
All state is exposed as signals, so you call them like functions in your template (e.g. `field.errors()`). The only exception is `path`, which is a plain value.
#### Next steps
Now that you know how to add fields to your form, continue to the [input components](/angular/guides/input-components.md) guide to learn about creating reusable input components for your forms.
### Input components
To make your code more readable, we recommend that you develop your own input components if you are not using a prebuilt UI library. There you can encapsulate logic to display error messages, for example.
> If you're already a bit more experienced, you can use the input components we developed for our [playground](/playground/login/) as a starting point. You can find the code in our GitHub repository [here](https://github.com/open-circle/formisch/tree/main/playgrounds/angular/src/components).
#### Why input components?
Currently, your fields might look something like this:
```angular-html
@if (field.errors(); as errors) {
{{ errors[0] }}
}
```
If CSS and a few more functionalities are added here, the code quickly becomes confusing. In addition, you have to rewrite the same code for almost every form field.
Our goal is to develop a `TextInputComponent` so that the code ends up looking like this:
```angular-html
```
#### Create an input component
In the first step, you create a new file for the `TextInputComponent`. The component takes the field store as an input and passes it on to the [`[formischControl]`](/angular/api/formischControl.md) directive.
Keep the component generic over the schema and the field path. The field store is invariant because of its `setInput` method, so a component that widens the input to the default `FieldStore` cannot accept a concrete field.
```ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
type FieldStore,
FormischControl,
type FormSchema,
type RequiredPath,
} from '@formisch/angular';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-text-input',
imports: [FormischControl],
template: `…`,
})
export class TextInputComponent<
TSchema extends FormSchema = FormSchema,
TFieldPath extends RequiredPath = RequiredPath,
> {
readonly field = input.required>();
readonly type = input('text');
readonly label = input();
readonly placeholder = input();
readonly required = input(false);
}
```
##### Template code
After that, you can add the template code. Use `field().name()` as the `id` so that the label and the error message reference the input element.
```ts
template: `
@if (label(); as label) {
}
@if (field().errors(); as errors) {
{{ errors[0] }}
}
`;
```
You don't need to set `aria-invalid` yourself. The [`[formischControl]`](/angular/api/formischControl.md) directive keeps it in sync with the field's errors.
##### Next steps
You can now build on this code and add CSS, for example. You can also follow the procedure to create other components such as `CheckboxComponent`, `SliderComponent`, `SelectComponent` and `FileInputComponent`.
##### Final code
Below is an overview of the entire code of the `TextInputComponent`.
```ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
type FieldStore,
FormischControl,
type FormSchema,
type RequiredPath,
} from '@formisch/angular';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-text-input',
imports: [FormischControl],
template: `
@if (label(); as label) {
}
@if (field().errors(); as errors) {
{{ errors[0] }}
}
`,
})
export class TextInputComponent<
TSchema extends FormSchema = FormSchema,
TFieldPath extends RequiredPath = RequiredPath,
> {
readonly field = input.required>();
readonly type = input('text');
readonly label = input();
readonly placeholder = input();
readonly required = input(false);
}
```
#### Next steps
Now that you know how to create reusable input components, continue to the [handle submission](/angular/guides/handle-submission.md) guide to learn how to process form data when the user submits the form.
### Handle submission
Now your first form is almost ready. There is only one little thing missing and that is the data processing when the form is submitted.
#### Submit event
To process the values on submission, you pass a function to the `[formischSubmit]` input of the [`[formischForm]`](/angular/api/formischForm.md) directive. The first parameter passed to the function contains the validated form values.
```ts
import { Component } from '@angular/core';
import {
FormischForm,
injectForm,
type SubmitHandler,
} from '@formisch/angular';
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
@Component({
selector: 'app-login',
imports: [FormischForm],
template: `
`,
})
export class LoginComponent {
readonly loginForm = injectForm({ schema: LoginSchema });
readonly handleSubmit: SubmitHandler = (values) => {
// Process the validated form values
console.log(values); // { email: string, password: string }
};
}
```
The [`SubmitHandler`](/core/api/SubmitHandler.md) type ensures type safety for your submission handler, automatically inferring the types of validated values from your schema. If you need access to the submit event, use [`SubmitEventHandler`](/core/api/SubmitEventHandler.md) instead.
> **Important:** Declare the handler as an arrow function property, not as a regular class method. Formisch receives it as a function reference, so a method would lose its `this` binding.
##### Prevent default
When the form is submitted, `event.preventDefault()` is executed by default to prevent the window from reloading so that the values can be processed directly in the browser and the state of the form is preserved.
##### Loading state
While the form is being submitted, you can use `loginForm.isSubmitting()` to display a loading animation and disable the submit button:
```angular-html
```
The form store also provides other signals like `isSubmitted`, `isValidating`, `isTouched`, `isDirty`, `isValid`, and `errors` for tracking form state. Note that `errors()` only contains validation errors at the root level of the form — to get all errors from all fields, use the [`getDeepErrors`](/methods/api/getDeepErrors.md) method.
##### Async submission
The submit handler can be asynchronous, allowing you to perform API calls or other async operations. Formisch awaits the returned promise, so `isSubmitting()` stays `true` until it settles:
```ts
readonly handleSubmit: SubmitHandler = async (values) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (response.ok) {
// Handle successful login
console.log('Login successful!');
} else {
// Handle error
console.error('Login failed');
}
} catch (error) {
console.error('Error during submission:', error);
}
};
```
The directive also registers the pending submission with Angular's `PendingTasks`, so server-side rendering waits for it and tests remain stable.
##### Trigger submission
If you want to trigger submission programmatically from outside the form, you can use the [`submit`](/methods/api/submit.md) method. It calls `requestSubmit()` on the underlying form element:
```ts
import { submit } from '@formisch/angular';
@Component({
selector: 'app-login',
imports: [FormischForm],
template: `
`,
})
export class LoginComponent {
readonly loginForm = injectForm({ schema: LoginSchema });
readonly handleSubmit: SubmitHandler = (values) => {
console.log(values);
};
handleExternalSubmit(): void {
submit(this.loginForm);
}
}
```
#### Submit without \
`,
})
export class LoginComponent {
readonly loginForm = injectForm({
schema: LoginSchema,
validate: 'change',
});
readonly handleSubmit = (output: v.InferOutput) => {
console.log(output);
};
}
```
`isEdited()` is the right signal for this. `isTouched()` would reveal the error as soon as the user tabs through a field without changing it, and `isDirty()` would hide the error again if the user clears an invalid value back to its initial state. `isEdited()` avoids both: it turns on only after a real change and stays on. Pair it with `validate: 'input'` (or `'change'`) so the error appears as soon as the field becomes invalid, and with `isSubmitted()` so that the errors of all fields — including the ones the user never touched — become visible after a submit attempt.
##### Reuse the guard across fields
Writing this guard at every field gets repetitive. The common fix is to move it into a small reusable component, either on its own or folded into the `TextInputComponent` from the [input components](/angular/guides/input-components.md) guide, so the `isEdited() || isSubmitted()` logic lives in exactly one place:
```ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-field-error',
template: `
@if (errors(); as errors) {
@if (isEdited() || isSubmitted()) {
{{ errors[0] }}
}
}
`,
})
export class FieldErrorComponent {
readonly errors = input.required<[string, ...string[]] | null>();
readonly isEdited = input.required();
readonly isSubmitted = input.required();
}
```
Add `FieldErrorComponent` to the `imports` array of the components that use it. Each field then stays focused on its input:
```angular-html
```
#### Validating state
When your schema contains asynchronous checks, validation does not resolve instantly. The form's `isValidating()` state is `true` while any validation is in flight, which you can use to show a loading indicator or to disable the submit button until validation settles.
```ts
import { Component } from '@angular/core';
import { FormischForm, injectForm } from '@formisch/angular';
import * as v from 'valibot';
import { isUsernameAvailable } from '../api';
const SignUpSchema = v.objectAsync({
username: v.pipeAsync(
v.string(),
v.nonEmpty(),
v.checkAsync(isUsernameAvailable, 'Username is already taken.')
),
});
@Component({
selector: 'app-sign-up',
imports: [FormischForm],
template: `
`,
})
export class SignUpComponent {
readonly signUpForm = injectForm({
schema: SignUpSchema,
validate: 'blur',
});
readonly handleSubmit = (output: v.InferOutput) => {
console.log(output);
};
}
```
`isValidating()` and `isSubmitting()` overlap but are not the same. `isValidating()` is `true` whenever validation runs — triggered by a field event or as part of a submit. `isSubmitting()` is `true` for the entire submission, which covers both validating the form and running your submit handler. So during a submit both are `true` while the form validates, and once validation passes only `isSubmitting()` stays `true` while your handler runs.
#### Further reading
To process your form's values once they pass validation, see the [handle submission](/angular/guides/handle-submission.md) guide. For working with the fields a user has changed, see the [dirty fields](/angular/guides/dirty-fields.md) guide.
### Special inputs
As listed in our features, the library supports all native HTML form fields. This includes the HTML `` element as well as special cases of the `` element.
> In our [playground](/playground/special/) you can take a look at such fields and test them out.
The [`[formischControl]`](/angular/api/formischControl.md) directive knows how to read from and write to each of these element types, so in every example below you only bind the directive — never `[value]`, `[checked]` or `[selected]`.
#### Checkbox
A simple checkbox represents a boolean and is `true` when checked or `false` otherwise.
```angular-html
```
However, you can also use multiple checkboxes to represent an array of strings. For this you simply have to add the `value` attribute to each HTML `` element. All checkboxes of the group are bound to the same field.
```angular-html
@for (fruit of fruits; track fruit.value) {
}
```
```ts
protected readonly fruits = [
{ label: 'Bananas', value: 'bananas' },
{ label: 'Apples', value: 'apples' },
{ label: 'Grapes', value: 'grapes' },
];
```
> The `value` attribute is the one binding you do set yourself, since it identifies the option rather than holding the field's state.
#### Radio
A group of radio buttons, while similar to an array of checkboxes, will only allow you to select one of the options based on which button is checked.
```angular-html
@for (color of colors; track color.value) {
}
```
```ts
protected readonly colors = [
{ label: 'Red', value: 'red' },
{ label: 'Green', value: 'green' },
{ label: 'Blue', value: 'blue' },
];
```
#### Select
An HTML `` element allows you to select a string from a predefined list of options.
```angular-html
```
However, if you set the `multiple` attribute, multiple options can be selected making the field represent an array of strings.
```angular-html
```
A `` silently ignores a value for which no matching `` exists yet. This happens when the options are rendered asynchronously, for example after a `resource` or an HTTP request resolves. Formisch handles this for you: the directive observes the option elements and re-applies the field input as soon as a matching option appears, so a value set via `initialInput` or [`setInput`](/methods/api/setInput.md) is not lost.
##### Placeholder option
To show a placeholder while the field is empty, add a disabled option with an empty value. Because the directive writes an empty string for a nullish input, the browser selects that option instead of falling back to the first real one.
```angular-html
```
#### File
For the HTML `` element it works similar to the HTML `` element. Without the `multiple` attribute it represents a single file and with, a list of files.
```angular-html
```
With the `multiple` attribute, users can select multiple files:
```angular-html
```
Browsers do not allow a file selection to be set programmatically, so a file input is the one element Formisch cannot write into. It can only clear it: when the field input becomes empty — for example through [`reset`](/methods/api/reset.md) — the directive resets the element as well.
### Controlled fields
In Formisch for Angular, every field bound with the [`[formischControl]`](/angular/api/formischControl.md) directive is controlled for you. This is different from our other framework packages, where you have to bind the value of the element yourself.
#### How it works
The directive writes the field's input into the element after every render in which the input changed. It knows the quirks of each element type, so it sets `value` on a text input, `checked` on a checkbox or radio, and the selected state of the options of a ``.
```angular-html
```
That single binding is all you need for initial values, for programmatic updates via [`setInput`](/methods/api/setInput.md), and for [`reset`](/methods/api/reset.md) to be reflected in the UI. There is no `[value]`, `[checked]` or `[(ngModel)]` to add — and you must not add them, because the directive already owns the element's value.
> Writing happens in Angular's render write phase, after sibling DOM such as the options of a `` has been created. That ordering is what makes a select show the right option on first render.
The directive additionally keeps the element's `name` attribute and its `aria-invalid` attribute in sync, and registers the element with the field so that [`focus`](/methods/api/focus.md) and the automatic focus of the first invalid field on submit work.
#### Reading the value
To render the current value somewhere else — a character counter, a preview, a summary — read the `input` signal of the field:
```angular-html
{{ field.input()?.length ?? 0 }} / 500
```
Because it is a signal, only the parts of your template that actually read it update when the value changes.
#### Setting the value
To change a field's value from your component class, you have two options:
- `field.setInput(value)` on the field store when you already have the field
- The [`setInput`](/methods/api/setInput.md) method when you only
have the form store
```ts
readonly form = injectForm({ schema: ProfileSchema });
resetUsername(): void {
setInput(this.form, { path: ['username'], input: '' });
}
```
Both update the field input and validate it like an `input` event would, so whether validation runs is controlled by your `validate` and `revalidate` config. The bound element follows automatically.
#### Fields without a native element
If you use a UI library whose components do not expose their underlying native element, you cannot apply the [`[formischControl]`](/angular/api/formischControl.md) directive. In that case, control the field yourself by reading `field.input()` and writing back with `field.setInput(…)`:
```angular-html
@if (field.errors(); as errors) {
{{ errors[0] }}
}
```
Note that a field controlled this way is not registered as an element of the form. Features that need a DOM element — focusing the first invalid field on submit, and the [`focus`](/methods/api/focus.md) method — will skip it.
#### Numbers and dates
An `` and an `` both expose their value as a string, and that string is what Formisch stores. Let your schema do the conversion so the validated output has the type you want:
```ts
const ProfileSchema = v.object({
// Input: string from the number input, output: number
age: v.pipe(
v.string(),
v.nonEmpty('Please enter your age.'),
v.transform(Number),
v.minValue(18)
),
// Input and output: 'yyyy-mm-dd' string from the date input
birthday: v.pipe(v.string(), v.nonEmpty('Please enter your birthday.')),
});
```
This keeps the field input a plain string that the element can hold, while [your submit handler](/angular/guides/handle-submission.md) receives the transformed value.
#### File inputs
The HTML `` element is an exception, because browsers do not allow a file selection to be set programmatically. Formisch can only clear it. You can, however, control the UI around it. For inspiration, check out our [file input](https://github.com/open-circle/formisch/blob/main/playgrounds/angular/src/components/file-input.component.ts) component from the [playground](/playground/login/).
#### Next steps
Now that you understand controlled fields, you can explore more advanced topics like [nested fields](/angular/guides/nested-fields.md) and [field arrays](/angular/guides/field-arrays.md) to handle complex form structures.
### Nested fields
To add a little more structure to a complex form or to match the values of the form to your database, you can also nest your form fields in objects as deep as you like.
#### Schema definition
For example, in the schema below, the first and last name are grouped under the object with the key `name`.
```ts
import * as v from 'valibot';
const ContactSchema = v.object({
name: v.object({
first: v.pipe(v.string(), v.nonEmpty()),
last: v.pipe(v.string(), v.nonEmpty()),
}),
email: v.pipe(v.string(), v.email()),
message: v.pipe(v.string(), v.nonEmpty()),
});
```
#### Path array
When creating a nested field, use an array with multiple elements as the path to refer to the nested field. The array represents the path through the nested structure.
```angular-html
```
If you're using TypeScript, your editor will provide autocompletion for the path based on your schema structure.
#### Deep nesting
You can nest objects as deeply as needed. Simply extend the path array with additional keys:
```ts
import * as v from 'valibot';
const ProfileSchema = v.object({
user: v.object({
address: v.object({
street: v.string(),
city: v.string(),
country: v.string(),
}),
}),
});
```
```angular-html
```
#### Type safety
The path array is fully type-safe. TypeScript will validate that each element in your path corresponds to a valid key in your schema structure, providing autocompletion and compile-time errors if you reference a non-existent path.
With `strictTemplates` enabled in the `angularCompilerOptions` of your `tsconfig.json`, this check also covers your templates: an invalid path in a `*formischField` expression fails the build, and the field store in the template context is typed with the value at that path.
### Dirty fields
When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: [`getDirtyInput`](/methods/api/getDirtyInput.md), [`getDirtyPaths`](/methods/api/getDirtyPaths.md), and [`pickDirty`](/methods/api/pickDirty.md). This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others.
#### What "dirty" means
A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag.
> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood.
#### The three methods
##### `getDirtyInput`
Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input.
```ts
import { Component, effect, input } from '@angular/core';
import {
getDirtyInput,
injectForm,
reset,
type SubmitHandler,
} from '@formisch/angular';
import * as v from 'valibot';
const UserSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty()),
email: v.pipe(v.string(), v.email()),
});
interface User {
id: string;
name: string;
email: string;
}
@Component({
/* … */
})
export class ProfileComponent {
readonly user = input.required();
readonly profileForm = injectForm({ schema: UserSchema });
constructor() {
// Inputs are not available while the class fields initialize, so apply
// the server state as the baseline in an effect. It re-runs whenever the
// input changes, which keeps the dirty tracking aligned with the server.
effect(() => {
reset(this.profileForm, { initialInput: this.user() });
});
}
readonly handleSubmit: SubmitHandler = async () => {
const dirty = getDirtyInput(this.profileForm);
if (dirty) {
await api.patchUser(this.user().id, dirty);
}
};
}
```
If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`.
Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see [below](#why-pickdirty-exists)).
##### `getDirtyPaths`
Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state.
```ts
import { getDirtyPaths } from '@formisch/angular';
const dirtyPaths = getDirtyPaths(this.profileForm);
// e.g. [['email'], ['user', 'name']]
```
Arrays are atomic: only the array's own path is returned, never the paths of individual items.
##### `pickDirty`
Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts.
```ts
import { pickDirty, type SubmitHandler } from '@formisch/angular';
readonly handleSubmit: SubmitHandler = async (output) => {
const dirty = pickDirty(this.profileForm, { from: output });
if (dirty) {
await api.update(dirty);
}
};
```
#### Why `pickDirty` exists
Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler:
```ts
import * as v from 'valibot';
const Schema = v.object({
name: v.pipe(v.string(), v.trim()),
age: v.pipe(v.string(), v.toNumber()),
});
```
After validation, `output.name` has whitespace trimmed and `output.age` is a number. [`getDirtyInput`](/methods/api/getDirtyInput.md) would give you the raw strings, because the dirty state is bound to the form input — not to the validated output.
[`pickDirty(form, { from: output })`](/methods/api/pickDirty.md) is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types.
```ts
import { pickDirty, type SubmitHandler } from '@formisch/angular';
readonly handleSubmit: SubmitHandler = async (output) => {
// output.age is a number — pickDirty preserves that.
const dirty = pickDirty(this.form, { from: output });
if (dirty) {
await api.update(dirty);
}
};
```
If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape.
#### Atomic arrays
All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays.
Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement.
This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences.
#### Common patterns
The snippets below assume `this.form` is your form store (the result of [`injectForm`](/angular/api/injectForm.md)).
##### Skip submission when nothing changed
The form's `isDirty()` signal is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty()` first would just walk the tree twice.
```ts
import type { SubmitHandler } from '@formisch/angular';
readonly handleSubmit: SubmitHandler = async (output) => {
if (!this.form.isDirty()) {
return;
}
await api.update(output);
};
```
##### Send only the dirty raw input
```ts
import { getDirtyInput } from '@formisch/angular';
const dirty = getDirtyInput(this.form);
if (dirty) {
await api.patch(dirty);
}
```
##### Send only the dirty validated output
```ts
import { pickDirty, type SubmitHandler } from '@formisch/angular';
readonly handleSubmit: SubmitHandler = async (output) => {
const dirty = pickDirty(this.form, { from: output });
if (dirty) {
await api.update(dirty);
}
};
```
#### Performance
All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level.
Call these methods from submit or blur handlers — not from a `computed` or `effect` that re-runs on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events.
### Field arrays
A somewhat more special case are dynamically generated form fields based on an array. Since adding, removing, swapping and moving items can be a big challenge here, the library provides the [`*formischFieldArray`](/angular/api/formischFieldArray.md) directive which, in combination with various methods, makes it very easy for you to create such forms.
> In our [playground](/playground/todos/) you can take a look at a form with a field array and test them out.
#### Create a field array
##### Schema definition
In the following example we create a field array for a todo form with the following schema:
```ts
import * as v from 'valibot';
const TodoFormSchema = v.object({
heading: v.pipe(v.string(), v.nonEmpty()),
todos: v.pipe(
v.array(
v.object({
label: v.pipe(v.string(), v.nonEmpty()),
deadline: v.pipe(v.string(), v.nonEmpty()),
})
),
v.nonEmpty(),
v.maxLength(10)
),
});
```
##### FieldArray directive
To dynamically generate the form fields for the todos, you use the [`*formischFieldArray`](/angular/api/formischFieldArray.md) directive in combination with Angular's `@for` block. The field array provides an `items` signal that contains unique string identifiers. Use them as the `track` expression so Angular detects when an item is added, moved, or removed.
```ts
import { Component } from '@angular/core';
import {
FormischControl,
FormischField,
FormischFieldArray,
FormischForm,
injectForm,
} from '@formisch/angular';
import * as v from 'valibot';
const TodoFormSchema = v.object({
heading: v.pipe(v.string(), v.nonEmpty()),
todos: v.pipe(
v.array(
v.object({
label: v.pipe(v.string(), v.nonEmpty()),
deadline: v.pipe(v.string(), v.nonEmpty()),
})
),
v.nonEmpty(),
v.maxLength(10)
),
});
@Component({
selector: 'app-todos',
imports: [FormischForm, FormischField, FormischFieldArray, FormischControl],
template: `
`,
})
export class TodosComponent {
readonly todoForm = injectForm({
schema: TodoFormSchema,
initialInput: {
heading: '',
todos: [{ label: '', deadline: '' }],
},
});
readonly handleSubmit = (output: v.InferOutput) => {
console.log(output);
};
}
```
> Track by the item identifier, not by `$index`. The identifiers are stable across reorders, which lets Angular move the existing DOM nodes instead of recreating them — and lets Formisch keep each field's state attached to the right item.
##### Path array with index
As with [nested fields](/angular/guides/nested-fields.md), you use an array as the path. The key difference is that you include the index from the `@for` block to specify which array item you're referencing. This ensures the paths update correctly when items are added, moved, or removed.
```angular-html
```
#### Use array methods
Now you can use the [`insert`](/methods/api/insert.md), [`move`](/methods/api/move.md), [`remove`](/methods/api/remove.md), [`replace`](/methods/api/replace.md), and [`swap`](/methods/api/swap.md) methods to make changes to the field array. These methods automatically take care of rearranging all the fields.
Since these methods take the form store as their first argument, call them from a method of your component class rather than inline in the template.
##### Insert method
Add a new item to the array:
```ts
import { insert } from '@formisch/angular';
handleInsert(): void {
insert(this.todoForm, {
path: ['todos'],
initialInput: { label: '', deadline: '' },
});
}
```
```angular-html
```
The `at` option can be used to specify the index where the item should be inserted. If not provided, the item is added to the end of the array.
##### Remove method
Remove an item from the array:
```ts
import { remove } from '@formisch/angular';
handleRemove(index: number): void {
remove(this.todoForm, { path: ['todos'], at: index });
}
```
```angular-html
```
##### Move method
Move an item from one position to another:
```ts
import { move } from '@formisch/angular';
handleMoveFirstToEnd(length: number): void {
move(this.todoForm, { path: ['todos'], from: 0, to: length - 1 });
}
```
```angular-html
```
##### Swap method
Swap two items in the array:
```ts
import { swap } from '@formisch/angular';
handleSwapFirstTwo(): void {
swap(this.todoForm, { path: ['todos'], at: 0, and: 1 });
}
```
```angular-html
```
##### Replace method
Replace an item with new data:
```ts
import { replace } from '@formisch/angular';
handleReplaceFirst(): void {
replace(this.todoForm, {
path: ['todos'],
at: 0,
initialInput: {
label: 'New task',
deadline: new Date().toISOString().split('T')[0],
},
});
}
```
```angular-html
```
#### Nested field arrays
If you need to nest multiple field arrays, the path array syntax makes it straightforward. Simply extend the path with additional array indices:
```ts
import * as v from 'valibot';
const NestedSchema = v.object({
categories: v.array(
v.object({
name: v.string(),
items: v.array(v.object({ title: v.string() })),
})
),
});
```
```angular-html
@for (category of categoryArray.items(); track category; let i = $index) {
@for (item of itemArray.items(); track item; let j = $index) {
}
}
```
You can nest field arrays as deeply as you like. You will also find a suitable example of this in our [playground](/playground/nested/).
#### Field array validation
As with fields, you can validate field arrays using Valibot's array validation functions. For example, to limit the length of the array:
```ts
const TodoFormSchema = v.object({
todos: v.pipe(
v.array(
v.object({
label: v.string(),
deadline: v.string(),
})
),
v.minLength(1),
v.maxLength(10)
),
});
```
The validation errors for the field array itself are available in `fieldArray.errors()` and can be displayed alongside the array.
### TypeScript
Since the library is written in TypeScript and we put a lot of emphasis on the development experience, you can expect maximum TypeScript support. Types are automatically inferred from your [Valibot schemas](/angular/guides/define-your-form.md), providing type safety throughout your forms.
#### Strict templates
Formisch's structural directives declare a typed template context through Angular's `ngTemplateContextGuard`. To benefit from that in your templates, enable `strictTemplates` in the `angularCompilerOptions` of your `tsconfig.json`:
```ts
{
"angularCompilerOptions": {
"strictTemplates": true
}
}
```
With strict templates, an invalid field path is a compile error in the template itself, and the field store bound with `let field` is typed with the value at that path.
#### Type inference
Formisch uses Valibot's type inference to automatically derive TypeScript types from your schemas. You don't need to define separate types — they're inferred automatically.
```ts
import { Component } from '@angular/core';
import { FormischForm, injectForm } from '@formisch/angular';
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
@Component({
selector: 'app-login',
imports: [FormischForm],
template: `
`,
})
export class LoginComponent {
// TypeScript knows the form structure from the schema
// loginForm is of type FormStore
readonly loginForm = injectForm({ schema: LoginSchema });
readonly handleSubmit = (output: v.InferOutput) => {
console.log(output.email); // ✓ Type-safe
// console.log(output.username); // ✗ TypeScript error
};
}
```
##### Input and output types
Valibot schemas can have different input and output types when using transformations. Formisch provides proper typing for both: the field input keeps the input type that the HTML element holds, while your submit handler receives the transformed output.
```ts
const ProfileSchema = v.object({
age: v.pipe(
v.string(), // Input type: string (from HTML input)
v.transform((input) => Number(input)), // Output type: number
v.number()
),
birthDate: v.pipe(
v.string(), // Input type: string (ISO date string)
v.transform((input) => new Date(input)), // Output type: Date
v.date()
),
});
```
```ts
@Component({
selector: 'app-profile',
imports: [FormischForm, FormischField, FormischControl],
template: `
`,
})
export class ProfileComponent {
readonly profileForm = injectForm({ schema: ProfileSchema });
readonly handleSubmit = (output: v.InferOutput) => {
// output is: { age: number; birthDate: Date }
console.log(output.age); // number
console.log(output.birthDate); // Date
};
}
```
##### Type-safe paths
Field paths are fully type-checked. TypeScript will provide autocompletion and catch invalid paths at compile time — in your class and, with `strictTemplates`, in your templates.
```ts
const UserSchema = v.object({
profile: v.object({
name: v.object({
first: v.string(),
last: v.string(),
}),
email: v.string(),
}),
});
```
```angular-html
```
#### Type-safe inputs
To pass your form to another component, use the [`FormStore`](/angular/api/FormStore.md) type along with your schema type to get full type safety.
```ts
import { Component, input } from '@angular/core';
import { FormischForm, type FormStore } from '@formisch/angular';
@Component({
selector: 'app-form-content',
imports: [FormischForm],
template: `
`,
})
export class FormContentComponent {
readonly of = input.required>();
readonly handleSubmit = (output: v.InferOutput) => {
console.log(output);
};
}
```
#### Generic field components
A component that owns a single field takes the form as an input and creates the field with [`injectField`](/angular/api/injectField.md). Type the input with [`FormStore`](/angular/api/FormStore.md) and the concrete schema the component belongs to.
```ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
FormischControl,
type FormStore,
injectField,
} from '@formisch/angular';
import * as v from 'valibot';
const EmailSchema = v.object({ email: v.pipe(v.string(), v.email()) });
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-email-input',
imports: [FormischControl],
template: `
@if (field.errors(); as errors) {
{{ errors[0] }}
}
`,
})
export class EmailInputComponent {
readonly of = input.required>();
protected readonly field = injectField(this.of, { path: ['email'] });
}
```
Passing a form whose schema has no `email` field of type `string` is a compile error, and `field.input()` is typed as `string | undefined` inside the component.
> Do not make such a component generic over the schema (for example with `TSchema extends v.GenericSchema<{ email: string }>`). [`ValidPath`](/core/api/ValidPath.md) is computed from the inferred input of the schema, so it cannot be resolved while `TSchema` is still an unresolved type parameter, and the path is rejected. Use a concrete schema as above, or take the field store as an input as shown next.
##### Keep components generic over the field
When a component takes a **field store** as an input instead of the form, make it generic over the schema and the path. The field store is invariant, because `setInput` accepts the field's value type. A component that declares a bare `FieldStore` input therefore cannot accept a concrete field store.
```ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
type FieldStore,
FormischControl,
type FormSchema,
type RequiredPath,
} from '@formisch/angular';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-text-input',
imports: [FormischControl],
template: ``,
})
export class TextInputComponent<
TSchema extends FormSchema = FormSchema,
TFieldPath extends RequiredPath = RequiredPath,
> {
readonly field = input.required>();
readonly type = input('text');
}
```
#### Available types
Most types you need can be imported from `@formisch/angular`. You can find all available types in our [API reference](/angular/api/).
- [`FormStore`](/angular/api/FormStore.md) - The form store type
- [`FieldStore`](/angular/api/FieldStore.md) - The field store
type
- [`FieldArrayStore`](/angular/api/FieldArrayStore.md) - The
field array store type
- [`SignalOrValue`](/angular/api/SignalOrValue.md) - Type for a
value that may be a signal
- [`ValidPath`](/core/api/ValidPath.md) - Type for valid field
paths
- [`ValidArrayPath`](/core/api/ValidArrayPath.md) - Type for
valid array field paths
- [`FormSchema`](/core/api/FormSchema.md) - Form schema type
required at the root
- [`Schema`](/core/api/Schema.md) - Base schema type from Valibot
- [`SubmitHandler`](/core/api/SubmitHandler.md) - Type for submit
handlers without the event
- [`SubmitEventHandler`](/core/api/SubmitEventHandler.md) - Type
for submit handlers with the event
### Architecture
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(initialValue: T): Signal {
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` 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`](https://github.com/open-circle/formisch/blob/main/packages/core/src/types/signal/signal.ts) with the same minimal interface:
```ts
interface Signal {
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 ``) 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 `` — already exists when the value is applied. And because a `` 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).
## Migration guides
### Migrate from Reactive Forms
This guide walks you through migrating a form from Angular's built-in [Reactive Forms](https://angular.dev/guide/forms/reactive-forms) to Formisch. It explains the mental-model differences, shows the same form implemented in both libraries, and maps Reactive Forms' APIs, options, and state fields to their Formisch counterparts.
Migrating is a rewrite of your form layer, not a drop-in replacement. That said, the conceptual gap is small: both are headless and keep the form's state outside of the template. The main work is consolidating your validators into a single root Valibot schema and replacing `FormGroup`/`FormControl` with Formisch's functions and directives.
Both libraries can coexist in the same application, so you can migrate one form at a time instead of doing everything in a single step. Because Formisch's directives are standalone, a component that still imports `ReactiveFormsModule` is unaffected by a component next to it that imports `FormischForm`.
#### Key differences
The biggest shift is that Formisch is strictly schema-first. In Reactive Forms, the shape of the form is declared as a tree of `FormGroup`, `FormArray` and `FormControl` instances, its TypeScript types come from that tree (or from an interface you maintain alongside it), and its validation lives in `Validators` attached to each control. In Formisch, a single Valibot schema is the one source of truth for all three: the structure, the types, and the rules.
Validation also runs differently:
- **Location**: Reactive Forms attaches validator functions per control, plus group-level validators for cross-field rules. Formisch always parses the entire form against the schema in a single pass and distributes the resulting issues to the individual fields, so cross-field rules work without extra wiring.
- **Timing**: Reactive Forms validates on every value change by default and is configured per control with `updateOn: 'change' | 'blur' | 'submit'`. Formisch controls timing form-wide with two options on [`injectForm`](/angular/api/injectForm.md): `validate` (first validation, defaults to `'submit'`) and `revalidate` (subsequent validations, defaults to `'input'`).
- **Field addressing**: Reactive Forms identifies fields by control name strings or dotted paths like `'members.0.email'`. Formisch uses path arrays like `['members', 0, 'email']` that are fully type-checked against your schema — including inside your templates when `strictTemplates` is enabled.
- **Form state**: Reactive Forms exposes state as plain properties (`valid`, `dirty`, `touched`, `pending`) that are read during change detection, and as RxJS streams (`valueChanges`, `statusChanges`). Formisch exposes everything as Angular signals, so `form.isValid()` can be read directly in an `OnPush` or zoneless template without subscriptions.
- **Errors**: Reactive Forms produces `ValidationErrors` objects keyed by validator name (`{ required: true }`) that you translate into messages in the template. In Formisch, the message is part of the schema, and `field.errors()` is a ready-to-render array of strings.
#### Side-by-side example
The following login form has two validated fields and an async submit handler. The Formisch version implements the exact same behavior.
##### Reactive Forms
```ts
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
`,
})
export class LoginComponent {
private readonly formBuilder = inject(FormBuilder);
readonly isSubmitting = signal(false);
readonly loginForm = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
get email() {
return this.loginForm.controls.email;
}
get password() {
return this.loginForm.controls.password;
}
async handleSubmit(): Promise {
if (this.loginForm.invalid) {
this.loginForm.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
try {
await this.api.login(this.loginForm.getRawValue());
} finally {
this.isSubmitting.set(false);
}
}
}
```
##### Formisch
```ts
import { Component } from '@angular/core';
import {
FormischControl,
FormischField,
FormischForm,
injectForm,
type SubmitHandler,
} from '@formisch/angular';
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email address is badly formatted.')
),
password: v.pipe(
v.string(),
v.nonEmpty('Please enter your password.'),
v.minLength(8, 'Your password must have 8 characters or more.')
),
});
@Component({
selector: 'app-login',
imports: [FormischForm, FormischField, FormischControl],
template: `
`,
})
export class LoginComponent {
readonly loginForm = injectForm({ schema: LoginSchema });
readonly handleSubmit: SubmitHandler = async (values) => {
await this.api.login(values);
};
}
```
The Formisch version needs no `isSubmitting` signal of its own, no manual `invalid` check before submitting, and no per-validator error translation in the template: the submit handler only runs when the form is valid, `isSubmitting()` is tracked for you while the returned promise is pending, and the messages come from the schema.
#### Migration steps
##### Install Formisch
```bash
npm install @formisch/angular valibot
```
Formisch's directives are standalone, so there is no module to add to your `AppModule`. You can leave `ReactiveFormsModule` imported in the components you have not migrated yet.
##### Move validation into a Valibot schema
Collect the validators of every control into one schema. Each `Validators` entry has a direct Valibot counterpart, and this is where you attach the error message that Reactive Forms would have kept in the template.
```ts
const LoginSchema = v.object({
// Validators.required + Validators.email
email: v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email address is badly formatted.')
),
// Validators.required + Validators.minLength(8)
password: v.pipe(
v.string(),
v.nonEmpty('Please enter your password.'),
v.minLength(8, 'Your password must have 8 characters or more.')
),
});
```
Group-level validators — the classic "passwords must match" — become a check on the object instead of a `ValidatorFn` on the `FormGroup`:
```ts
const SignUpSchema = v.pipe(
v.object({
password: v.pipe(v.string(), v.minLength(8)),
confirmPassword: v.string(),
}),
v.forward(
v.check(
(input) => input.password === input.confirmPassword,
'The passwords do not match.'
),
['confirmPassword']
)
);
```
Async validators map onto Valibot's async API (`v.objectAsync`, `v.pipeAsync`, `v.checkAsync`), which Formisch parses with `safeParseAsync` out of the box.
##### Replace the form setup
Replace the `FormBuilder` tree with a single [`injectForm`](/angular/api/injectForm.md) call. Initial values move from the control definitions to `initialInput`.
```ts
// Before
readonly loginForm = this.formBuilder.nonNullable.group({
email: ['user@example.com', [Validators.required, Validators.email]],
password: ['', [Validators.required]],
});
// After
readonly loginForm = injectForm({
schema: LoginSchema,
initialInput: { email: 'user@example.com' },
});
```
Like `inject`, [`injectForm`](/angular/api/injectForm.md) must be called in an injection context — a field initializer or the constructor.
##### Replace field bindings
`[formGroup]` on the form element becomes `[formischForm]`, and each `formControlName` becomes a `*formischField` that provides the field store plus a `[formischControl]` that binds it to the element.
```angular-html
```
Nested groups do not need `formGroupName` wrappers. Address the nested field with its full path instead: `['address', 'city']`.
##### Update submission handling
`(ngSubmit)` calls your method; `[formischSubmit]` receives it. Formisch validates first, focuses the first invalid field, awaits your handler, and tracks `isSubmitting()` for you — so the guard clause and the manual flag both disappear.
```ts
// Before
async handleSubmit(): Promise {
if (this.loginForm.invalid) {
this.loginForm.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
try {
await this.api.login(this.loginForm.getRawValue());
} finally {
this.isSubmitting.set(false);
}
}
// After
readonly handleSubmit: SubmitHandler = async (values) => {
await this.api.login(values);
};
```
> Declare the handler as an arrow function property. A regular class method passed as a reference would lose its `this`.
#### API mapping
| Reactive Forms | Formisch |
| -------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `ReactiveFormsModule` | `FormischForm`, `FormischField`, `FormischFieldArray`, `FormischControl` |
| `formBuilder.group({ … })` | [`injectForm({ schema })`](/angular/api/injectForm.md) |
| `new FormControl('')` | A field of the schema, read via [`injectField`](/angular/api/injectField.md) |
| `new FormArray([])` | `v.array(…)` in the schema + [`injectFieldArray`](/angular/api/injectFieldArray.md) |
| `[formGroup]="form"` | `[formischForm]="form"` |
| `formControlName="email"` | `*formischField="['email'] of form; let field"` + `[formischControl]="field"` |
| `formGroupName` / `formArrayName` | A longer path array, e.g. `['address', 'city']` |
| `(ngSubmit)="save()"` | `[formischSubmit]="save"` |
| `Validators.required` | `v.nonEmpty()` |
| `Validators.email` | `v.email()` |
| `Validators.minLength(n)` | `v.minLength(n)` |
| `Validators.min(n)` / `max(n)` | `v.minValue(n)` / `v.maxValue(n)` |
| `Validators.pattern(re)` | `v.regex(re)` |
| `updateOn: 'blur'` | `validate: 'blur'` on `injectForm` |
| `form.value` / `form.getRawValue()` | [`getInput(form)`](/methods/api/getInput.md) |
| `form.patchValue({ … })` | [`setInput(form, { path, input })`](/methods/api/setInput.md) |
| `form.reset()` / `form.reset(value)` | [`reset(form)`](/methods/api/reset.md) / `reset(form, { initialInput })` |
| `form.valid` / `form.invalid` | `form.isValid()` |
| `form.dirty` | `form.isDirty()` |
| `form.touched` | `form.isTouched()` |
| `form.pending` | `form.isValidating()` |
| `control.errors` | `field.errors()` (array of messages, or `null`) |
| `form.updateValueAndValidity()` | [`validate(form)`](/methods/api/validate.md) |
| `form.markAllAsTouched()` | Not needed — errors appear after the first submit |
| `form.valueChanges` (RxJS) | `field.input()` / `getInput(form)` read in a `computed` or `effect` |
| `form.statusChanges` (RxJS) | `form.isValid()` / `form.isValidating()` |
| `form.controls.email.setErrors({ … })` | [`setErrors(form, { path, errors })`](/methods/api/setErrors.md) |
| `formArray.push(control)` | [`insert(form, { path, initialInput })`](/methods/api/insert.md) |
| `formArray.removeAt(i)` | [`remove(form, { path, at: i })`](/methods/api/remove.md) |
#### Common patterns
##### Form state
Every piece of form state is a signal, so it is read as a function call and works without `async` pipes or subscriptions.
```angular-html
```
Note that you rarely need to disable the button on `invalid` with Formisch: submitting an invalid form runs validation, shows the errors, and focuses the first invalid field, which is friendlier than a permanently disabled button.
To react to value changes in code, read the value inside an `effect` instead of subscribing to `valueChanges`:
```ts
constructor() {
effect(() => {
const country = getInput(this.form, { path: ['country'] });
// runs whenever the country field changes
});
}
```
##### Validation timing
```ts
// Before: per control
new FormControl('', { validators: [Validators.required], updateOn: 'blur' });
// After: once per form
readonly form = injectForm({
schema: Schema,
validate: 'blur',
revalidate: 'input',
});
```
##### Field arrays
A `FormArray` becomes a `v.array(…)` in the schema, rendered with [`*formischFieldArray`](/angular/api/formischFieldArray.md). Instead of pushing control instances, you call the array methods with a path.
```ts
const TodosSchema = v.object({
todos: v.pipe(
v.array(v.object({ label: v.pipe(v.string(), v.nonEmpty()) })),
v.nonEmpty('Please add at least one todo.')
),
});
```
```angular-html
@for (item of fieldArray.items(); track item; let index = $index) {
}
```
```ts
handleInsert(): void {
insert(this.form, { path: ['todos'], initialInput: { label: '' } });
}
handleRemove(index: number): void {
remove(this.form, { path: ['todos'], at: index });
}
```
Track by the item identifier from `fieldArray.items()`, not by `$index`, so reordering moves the existing DOM instead of recreating it. See the [field arrays](/angular/guides/field-arrays.md) guide for `move`, `swap` and `replace`.
##### Reset
```ts
// Before
this.loginForm.reset({ email: '', password: '' });
// After
reset(this.loginForm, { initialInput: { email: '', password: '' } });
```
As in Reactive Forms, resetting also clears the touched and dirty state. Passing `initialInput` additionally makes the new values the baseline for dirty tracking.
##### Special inputs
Reactive Forms needs a `ControlValueAccessor` for anything that is not a plain input, and community directives for checkbox groups. Formisch's [`[formischControl]`](/angular/api/formischControl.md) directive handles every native element type directly, including checkbox and radio groups, multi-selects and file inputs:
```angular-html
@for (fruit of fruits; track fruit.value) {
}
```
If you have a component that implements `ControlValueAccessor` today, you don't need that interface anymore. Take the field store as an input and use `field.input()` and `field.setInput(…)` — see the [controlled fields](/angular/guides/controlled-fields.md) guide.
#### Next steps
Start with the [define your form](/angular/guides/define-your-form.md) guide to get comfortable with schema-first thinking, then read [validation](/angular/guides/validation.md) for the timing options and [field arrays](/angular/guides/field-arrays.md) if your forms are dynamic.
### Migrate from TanStack Form
This guide walks you through migrating a form from [TanStack Form](https://tanstack.com/form/latest) to Formisch. It explains the mental-model differences, shows the same form implemented in both libraries, and maps TanStack Form's APIs, options, and state fields to their Formisch counterparts.
Migrating is a rewrite of your form layer, not a drop-in replacement. That said, the conceptual gap is small: both libraries are headless, both keep form state outside the template, and both support schema validation. The main work is moving your validators into a single root Valibot schema and replacing TanStack's field API with Formisch's directives.
Both libraries can coexist in the same application, so you can migrate one form at a time. Since both export a function named `injectForm`, the import path determines which one a component uses.
#### Key differences
The biggest shift is where the form's shape comes from. In TanStack Form, the shape and the types come from `defaultValues`, and validation is configured separately per validator trigger (`onChange`, `onBlur`, `onSubmit`), optionally with a Standard Schema. In Formisch, a single Valibot schema is the one source of truth for the structure, the TypeScript types, and the rules — there is no `defaultValues` object to keep aligned with a schema.
Validation also runs differently:
- **Location**: TanStack Form can validate per field and per form, with a separate schema or function per trigger. Formisch always parses the entire form against one schema in a single pass and distributes the resulting issues to the individual fields.
- **Timing**: TanStack Form binds validation to the trigger you register it under (`validators: { onChange: … }`). Formisch controls timing form-wide with `validate` (first validation) and `revalidate` (subsequent validations) on [`injectForm`](/angular/api/injectForm.md).
- **Field addressing**: TanStack Form identifies fields by dot-notation strings like `'members[0].email'`. Formisch uses path arrays like `['members', 0, 'email']` that are fully type-checked against your schema — including inside templates when `strictTemplates` is enabled.
- **State access**: TanStack Form exposes state through a store, read in Angular with `injectStore(this.form, (state) => …)`. Formisch exposes each piece of state as its own signal on the form and field stores, so you read `form.isSubmitting()` directly.
- **Element binding**: With TanStack Form you wire `[value]`, `(input)` and `(blur)` on each element yourself. Formisch's [`[formischControl]`](/angular/api/formischControl.md) directive does all of that, including the quirks of checkboxes, radios, selects and file inputs.
#### Side-by-side example
The following login form has two validated fields and an async submit handler.
##### TanStack Form
```ts
import { Component } from '@angular/core';
import { injectForm, injectStore, TanStackField } from '@tanstack/angular-form';
import { z } from 'zod';
const LoginSchema = z.object({
email: z
.string()
.min(1, 'Please enter your email.')
.email('The email address is badly formatted.'),
password: z
.string()
.min(1, 'Please enter your password.')
.min(8, 'Your password must have 8 characters or more.'),
});
@Component({
selector: 'app-login',
imports: [TanStackField],
template: `
`,
})
export class LoginComponent {
readonly form = injectForm({
defaultValues: { email: '', password: '' },
validators: { onChange: LoginSchema },
onSubmit: async ({ value }) => {
await this.api.login(value);
},
});
readonly isSubmitting = injectStore(this.form, (state) => state.isSubmitting);
handleSubmit(event: SubmitEvent): void {
event.preventDefault();
event.stopPropagation();
void this.form.handleSubmit();
}
}
```
##### Formisch
```ts
import { Component } from '@angular/core';
import {
FormischControl,
FormischField,
FormischForm,
injectForm,
type SubmitHandler,
} from '@formisch/angular';
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email address is badly formatted.')
),
password: v.pipe(
v.string(),
v.nonEmpty('Please enter your password.'),
v.minLength(8, 'Your password must have 8 characters or more.')
),
});
@Component({
selector: 'app-login',
imports: [FormischForm, FormischField, FormischControl],
template: `
`,
})
export class LoginComponent {
readonly loginForm = injectForm({
schema: LoginSchema,
validate: 'change',
});
readonly handleSubmit: SubmitHandler = async (values) => {
await this.api.login(values);
};
}
```
The Formisch version has no `defaultValues` duplicating the schema, no manual `preventDefault`, no store selector for `isSubmitting`, and no per-element value and event wiring.
#### Migration steps
##### Install Formisch
```bash
npm install @formisch/angular valibot
```
##### Move validation into a Valibot schema
If you already validate with a Standard Schema, this is mostly a translation of one schema library into Valibot. Note that Formisch reads the error message from the schema, so make sure every rule carries the message you want to render.
```ts
const LoginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email address is badly formatted.')
),
});
```
Field-level validators registered under `validators` on a single field move into the corresponding property of the root schema. Cross-field rules that lived in the form-level `validators` become a `v.check` on the object, forwarded to the field that should show the message:
```ts
const SignUpSchema = v.pipe(
v.object({
password: v.pipe(v.string(), v.minLength(8)),
confirmPassword: v.string(),
}),
v.forward(
v.check(
(input) => input.password === input.confirmPassword,
'The passwords do not match.'
),
['confirmPassword']
)
);
```
##### Replace the form setup
`defaultValues` becomes `initialInput` — but only for the values you actually want to prefill. Fields without an initial value start from the schema's empty input.
```ts
// Before
readonly form = injectForm({
defaultValues: { email: '', password: '' },
validators: { onChange: LoginSchema },
onSubmit: async ({ value }) => { await this.api.login(value); },
});
// After
readonly loginForm = injectForm({
schema: LoginSchema,
validate: 'change',
});
```
The submit handler moves out of the config and into `[formischSubmit]` on the form element.
##### Replace field bindings
The `[tanstackField]` container becomes `*formischField`, and the manual value and event bindings collapse into `[formischControl]`.
```angular-html
```
##### Update submission handling
TanStack Form asks you to intercept the native submit event yourself. Formisch's [`[formischForm]`](/angular/api/formischForm.md) directive handles the event, validates, focuses the first invalid field, and tracks `isSubmitting()` while your handler runs.
```ts
// Before
handleSubmit(event: SubmitEvent): void {
event.preventDefault();
event.stopPropagation();
void this.form.handleSubmit();
}
// After — passed to [formischSubmit], called only when valid
readonly handleSubmit: SubmitHandler = async (values) => {
await this.api.login(values);
};
```
#### API mapping
| TanStack Form | Formisch |
| ------------------------------------------- | -------------------------------------------------------------------------------------- |
| `injectForm({ defaultValues, validators })` | [`injectForm({ schema, initialInput })`](/angular/api/injectForm.md) |
| `defaultValues` | `initialInput` |
| `validators: { onChange: schema }` | `schema` + `validate` / `revalidate` |
| `[tanstackField]` + `name="email"` | `*formischField="['email'] of form; let field"` |
| `field.api.state.value` | `field.input()` |
| `field.api.handleChange(value)` | `field.setInput(value)` (or just `[formischControl]`) |
| `field.api.handleBlur()` | Handled by `[formischControl]` |
| `field.api.state.meta.errors` | `field.errors()` |
| `field.api.state.meta.isTouched` | `field.isTouched()` |
| `field.api.state.meta.isDirty` | `field.isDirty()` |
| `injectStore(form, (s) => s.isSubmitting)` | `form.isSubmitting()` |
| `injectStore(form, (s) => s.canSubmit)` | `form.isValid()` / `form.isValidating()` |
| `injectStore(form, (s) => s.values)` | [`getInput(form)`](/methods/api/getInput.md) |
| `form.handleSubmit()` | [`submit(form)`](/methods/api/submit.md) |
| `onSubmit: async ({ value }) => …` | `[formischSubmit]="handleSubmit"` |
| `form.reset()` | [`reset(form)`](/methods/api/reset.md) |
| `form.setFieldValue(name, value)` | [`setInput(form, { path, input })`](/methods/api/setInput.md) |
| `form.validateField(name, cause)` | [`validate(form)`](/methods/api/validate.md) (validates the whole form) |
| `field.pushValue(item)` | [`insert(form, { path, initialInput })`](/methods/api/insert.md) |
| `field.removeValue(i)` | [`remove(form, { path, at: i })`](/methods/api/remove.md) |
| `field.swapValues(a, b)` | [`swap(form, { path, at, and })`](/methods/api/swap.md) |
| `field.moveValue(from, to)` | [`move(form, { path, from, to })`](/methods/api/move.md) |
#### Common patterns
##### Form state
No store selectors are needed. Every value is a signal on the form store:
```angular-html
```
Note that you rarely need to disable the button on invalid with Formisch: submitting an invalid form runs validation, shows the errors, and focuses the first invalid field, which is friendlier than a permanently disabled button. If you adopt that behavior, `[disabled]="loginForm.isValidating()"` is all that remains.
##### Validation timing
```ts
// Before: per trigger
validators: { onChange: Schema, onBlur: Schema }
// After: once per form
readonly form = injectForm({
schema: Schema,
validate: 'blur',
revalidate: 'input',
});
```
##### Field arrays
```ts
const TodosSchema = v.object({
todos: v.array(v.object({ label: v.pipe(v.string(), v.nonEmpty()) })),
});
```
```angular-html
@for (item of fieldArray.items(); track item; let index = $index) {
}
```
Where TanStack Form uses `mode="array"` and the field's own `pushValue`/`removeValue` helpers, Formisch uses the standalone [`insert`](/methods/api/insert.md) and [`remove`](/methods/api/remove.md) methods with a path. Track by the item identifier from `fieldArray.items()` rather than by `$index`.
##### Reset
```ts
// Before
this.form.reset();
// After
reset(this.form);
```
Pass `{ initialInput }` to `reset` when new server data should become the baseline for dirty tracking.
##### Special inputs
Checkboxes, radio groups, multi-selects and file inputs need no special handling: bind [`[formischControl]`](/angular/api/formischControl.md) and Formisch reads and writes the element correctly. See the [special inputs](/angular/guides/special-inputs.md) guide.
#### Next steps
Start with the [define your form](/angular/guides/define-your-form.md) guide to get comfortable with schema-first thinking, then read [validation](/angular/guides/validation.md) for the timing options and [field arrays](/angular/guides/field-arrays.md) if your forms are dynamic.