---
name: formisch
description: Form handling with Formisch, the schema-first and type-safe form library for Angular, Preact, Qwik, React, React Native, Solid, Svelte, and Vue. Use when creating forms, handling form state, validating inputs, working with field arrays, or using @formisch/* packages.
license: MIT
metadata:
  author: open-circle
  version: "1.3"
---

# Formisch

This skill helps AI agents work effectively with [Formisch](https://formisch.dev/), the schema-based, headless form library for modern frameworks.

## When to Use This Skill

- When the user asks about form handling with Formisch
- When managing form state and validation
- When working with Angular, Preact, Qwik, React, React Native, Solid, Svelte, or Vue forms
- When integrating Valibot schemas with forms

## Introduction

Formisch is a schema-based, headless form library that works across multiple frameworks. Key highlights:

- **Small bundle size** — Starting at ~2.5 kB
- **Schema-based validation** — Uses Valibot for type-safe validation
- **Headless design** — You control the UI completely
- **Type safety** — Full TypeScript support with autocompletion
- **Framework-native** — Native performance for each supported framework

### Supported Frameworks

| Framework    | Package                  | Hook/Primitive |
| ------------ | ------------------------ | -------------- |
| Angular      | `@formisch/angular`      | `injectForm`   |
| Preact       | `@formisch/preact`       | `useForm`      |
| Qwik         | `@formisch/qwik`         | `useForm$`     |
| React        | `@formisch/react`        | `useForm`      |
| React Native | `@formisch/react-native` | `useForm`      |
| SolidJS      | `@formisch/solid`        | `createForm`   |
| Svelte       | `@formisch/svelte`       | `createForm`   |
| Vue          | `@formisch/vue`          | `useForm`      |

## Installation

### 1. Install Valibot (peer dependency)

```bash
npm install valibot
```

### 2. Install Formisch for your framework

```bash
npm install @formisch/react         # React
npm install @formisch/angular       # Angular
npm install @formisch/vue           # Vue
npm install @formisch/solid         # SolidJS
npm install @formisch/preact        # Preact
npm install @formisch/svelte        # Svelte
npm install @formisch/qwik          # Qwik
npm install @formisch/react-native  # React Native
```

## Core Concepts

### Schema-First Design

Every form starts with a Valibot schema. Types are automatically inferred from the schema.

```ts
import * as v from "valibot";

const LoginSchema = v.object({
  email: v.pipe(
    v.string("Please enter your email."),
    v.nonEmpty("Please enter your email."),
    v.email("The email address is badly formatted."),
  ),
  password: v.pipe(
    v.string("Please enter your password."),
    v.nonEmpty("Please enter your password."),
    v.minLength(8, "Your password must have 8 characters or more."),
  ),
});
```

### Form Store

The form store manages all form state. Access it via the framework-specific hook/primitive.

**Form Store Properties:**

- `isSubmitting` — Form is currently being submitted
- `isSubmitted` — Form submission has been attempted
- `isValidating` — Validation is in progress
- `isTouched` — At least one field has been touched
- `isEdited` — At least one field has been edited
- `isDirty` — At least one field differs from initial value
- `isValid` — All fields pass validation
- `errors` — Root-level validation errors

### Field Store

Each field has its own reactive store with:

- `path` — Path array to the field
- `input` — Current field value
- `errors` — Field-specific errors
- `isTouched` — Field has been focused
- `isEdited` — Field value has been edited
- `isDirty` — Field value differs from initial value
- `isValid` — Field passes validation
- `props` — Props to spread onto native elements (Angular connects controls with `[formischControl]` instead)
- `onChange` (React and React Native) / `onInput` (Solid, Svelte, Preact, and Qwik) / `setInput` (Angular) — Sets the field input value programmatically. Use this when the field cannot be connected to a native element. In Vue, set `field.input` directly (for example with `v-model`).

Store reactivity is framework-specific. React, React Native, Solid, Svelte, and Vue expose plain reactive properties. Angular properties are signals and are called like `field.errors()`, except `path`, which is a plain value. Preact and Qwik properties are signals; use `.value` in conditions and ordinary TypeScript logic. Do not copy one framework's access syntax into another.

### Dirty Tracking

Formisch tracks two inputs per field:

- **Initial input** — Baseline for dirty tracking (server state)
- **Current input** — What the user is editing (client state)

`isDirty` becomes `true` when current input differs from initial input.

## Framework Examples

### Angular Example

Angular uses signals, dependency injection, and directives instead of a JSX component API.

```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.email()),
  password: v.pipe(v.string(), v.minLength(8)),
});

@Component({
  selector: "app-login",
  imports: [FormischForm, FormischField, FormischControl],
  template: `
    <form [formischForm]="loginForm" [formischSubmit]="handleSubmit">
      <ng-container *formischField="['email'] of loginForm; let field">
        <input [formischControl]="field" type="email" />
        @if (field.errors(); as errors) {
          <div>{{ errors[0] }}</div>
        }
      </ng-container>
      <button type="submit" [disabled]="loginForm.isSubmitting()">Login</button>
    </form>
  `,
})
export class LoginComponent {
  readonly loginForm = injectForm({ schema: LoginSchema });

  readonly handleSubmit: SubmitHandler<typeof LoginSchema> = (output) => {
    console.log(output);
  };
}
```

Let `[formischControl]` synchronize the native control. Do not add competing `[value]` or `[checked]` bindings except when `value` identifies an option in a radio or checkbox group.

### React Native Example

React Native has no DOM `<form>` element or Formisch `Form` component. Use `handleSubmit` and bind `field.props` to `TextInput`.

```tsx
import { Field, handleSubmit, useForm } from "@formisch/react-native";
import { Button, TextInput, View } from "react-native";
import * as v from "valibot";

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

export default function LoginScreen() {
  const loginForm = useForm({ schema: LoginSchema });
  const submitForm = handleSubmit(loginForm, (output) => console.log(output));

  return (
    <View>
      <Field of={loginForm} path={["email"]}>
        {(field) => (
          <TextInput
            {...field.props}
            value={field.input}
            autoCapitalize="none"
            keyboardType="email-address"
          />
        )}
      </Field>
      <Field of={loginForm} path={["password"]}>
        {(field) => (
          <TextInput {...field.props} value={field.input} secureTextEntry />
        )}
      </Field>
      <Button title="Login" onPress={submitForm} />
    </View>
  );
}
```

React Native text inputs are controlled, so always pass `value={field.input}`. Use `field.onChange(value)` for switches, sliders, pickers, and non-string values.

### React Example

```tsx
import { Field, Form, useForm } from "@formisch/react";
import type { SubmitHandler } from "@formisch/react";
import * as v from "valibot";

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

export default function LoginPage() {
  const loginForm = useForm({
    schema: LoginSchema,
  });

  const handleSubmit: SubmitHandler<typeof LoginSchema> = (output) => {
    console.log(output); // { email: string, password: string }
  };

  return (
    <Form of={loginForm} onSubmit={handleSubmit}>
      <Field of={loginForm} path={["email"]}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input} type="email" />
            {field.errors && <div>{field.errors[0]}</div>}
          </div>
        )}
      </Field>
      <Field of={loginForm} path={["password"]}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input} type="password" />
            {field.errors && <div>{field.errors[0]}</div>}
          </div>
        )}
      </Field>
      <button type="submit" disabled={loginForm.isSubmitting}>
        {loginForm.isSubmitting ? "Submitting..." : "Login"}
      </button>
    </Form>
  );
}
```

### Vue Example

```vue
<script setup lang="ts">
import { Field, Form, useForm } from "@formisch/vue";
import type { SubmitHandler } from "@formisch/vue";
import * as v from "valibot";

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

const loginForm = useForm({
  schema: LoginSchema,
});

const handleSubmit: SubmitHandler<typeof LoginSchema> = (output) => {
  console.log(output);
};
</script>

<template>
  <Form :of="loginForm" @submit="handleSubmit">
    <Field :of="loginForm" :path="['email']" v-slot="field">
      <div>
        <input v-model="field.input" v-bind="field.props" type="email" />
        <div v-if="field.errors">{{ field.errors[0] }}</div>
      </div>
    </Field>
    <Field :of="loginForm" :path="['password']" v-slot="field">
      <div>
        <input v-model="field.input" v-bind="field.props" type="password" />
        <div v-if="field.errors">{{ field.errors[0] }}</div>
      </div>
    </Field>
    <button type="submit">Login</button>
  </Form>
</template>
```

### SolidJS Example

```tsx
import { Field, Form, createForm } from "@formisch/solid";
import type { SubmitHandler } from "@formisch/solid";
import * as v from "valibot";

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

export default function LoginPage() {
  const loginForm = createForm({
    schema: LoginSchema,
  });

  const handleSubmit: SubmitHandler<typeof LoginSchema> = (output) => {
    console.log(output);
  };

  return (
    <Form of={loginForm} onSubmit={handleSubmit}>
      <Field of={loginForm} path={["email"]}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input} type="email" />
            {field.errors && <div>{field.errors[0]}</div>}
          </div>
        )}
      </Field>
      <Field of={loginForm} path={["password"]}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input} type="password" />
            {field.errors && <div>{field.errors[0]}</div>}
          </div>
        )}
      </Field>
      <button type="submit">Login</button>
    </Form>
  );
}
```

### Svelte Example

```svelte
<script lang="ts">
  import { createForm, Field, Form } from '@formisch/svelte';
  import type { SubmitHandler } from '@formisch/svelte';
  import * as v from 'valibot';

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

  const loginForm = createForm({
    schema: LoginSchema,
  });

  const handleSubmit: SubmitHandler<typeof LoginSchema> = (output) => {
    console.log(output);
  };
</script>

<Form of={loginForm} onsubmit={handleSubmit}>
  <Field of={loginForm} path={['email']}>
    {#snippet children(field)}
      <div>
        <input {...field.props} value={field.input} type="email" />
        {#if field.errors}
          <div>{field.errors[0]}</div>
        {/if}
      </div>
    {/snippet}
  </Field>
  <Field of={loginForm} path={['password']}>
    {#snippet children(field)}
      <div>
        <input {...field.props} value={field.input} type="password" />
        {#if field.errors}
          <div>{field.errors[0]}</div>
        {/if}
      </div>
    {/snippet}
  </Field>
  <button type="submit">Login</button>
</Form>
```

### Qwik Example

```tsx
import { Field, Form, useForm$ } from "@formisch/qwik";
import { component$ } from "@qwik.dev/core";
import * as v from "valibot";

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

export default component$(() => {
  const loginForm = useForm$(() => ({
    schema: LoginSchema,
  }));

  return (
    <Form of={loginForm} onSubmit$={(output) => console.log(output)}>
      <Field
        of={loginForm}
        path={["email"]}
        render$={(field) => (
          <div>
            <input {...field.props} value={field.input.value} type="email" />
            {field.errors.value && <div>{field.errors.value[0]}</div>}
          </div>
        )}
      />
      <Field
        of={loginForm}
        path={["password"]}
        render$={(field) => (
          <div>
            <input {...field.props} value={field.input.value} type="password" />
            {field.errors.value && <div>{field.errors.value[0]}</div>}
          </div>
        )}
      />
      <button type="submit">Login</button>
    </Form>
  );
});
```

## Form Configuration

```ts
const form = useForm({
  // Required: Valibot schema
  schema: MySchema,

  // Optional: Initial values (partial allowed)
  initialInput: {
    email: "user@example.com",
  },

  // Optional: Empty values for required fields without initial input
  // Required strings default to ''; number, boolean, and date to undefined
  emptyInput: {
    number: 0,
  },

  // Optional: When first validation occurs
  // Options: 'initial' | 'touch' | 'input' | 'change' | 'blur' | 'submit' (default)
  validate: "submit",

  // Optional: When a field is validated again once it already has an
  // error or the form has been submitted
  // Options: 'touch' | 'input' (default) | 'change' | 'blur' | 'submit'
  revalidate: "input",
});
```

In Qwik, `useForm$` must receive a function that returns the config, e.g. `useForm$(() => ({ schema: MySchema }))`. This allows Qwik to convert the config into a QRL.

Optional and nullable fields remain `undefined`. `emptyInput` only supplies fallbacks for required fields whose input is `undefined`.

## Field Paths

Paths are type-safe arrays that reference fields in your schema.

```tsx
// Top-level field
<Field of={form} path={['email']} />

// Nested field (schema: { user: { email: string } })
<Field of={form} path={['user', 'email']} />

// Array item field (schema: { todos: [{ label: string }] })
<Field of={form} path={['todos', 0, 'label']} />

// Dynamic array index
{items.map((item, index) => (
  <Field of={form} path={['todos', index, 'label']} key={item} />
))}
```

## Form Methods

All methods follow a consistent API pattern:

- **First parameter**: Form store
- **Second parameter**: Config object

### Reading Values

```ts
import {
  getDeepError,
  getDeepErrorEntries,
  getDeepErrorEntry,
  getDeepErrors,
  getErrors,
  getInput,
} from "@formisch/react";

// Get field value
const email = getInput(form, { path: ["email"] });

// Get entire form input
const allInputs = getInput(form);

// Get field errors
const emailErrors = getErrors(form, { path: ["email"] });

// Get all errors across all fields (including form-level errors)
const allErrors = getDeepErrors(form);

// Get all errors of a field and its descendants
const todoErrors = getDeepErrors(form, { path: ["todos"] });

// Get every error together with its field path
const errorEntries = getDeepErrorEntries(form);

// Get only the first error of a field and its descendants
const firstTodoError = getDeepError(form, { path: ["todos"] });

// Get only the first error together with its field path
const firstErrorEntry = getDeepErrorEntry(form);
```

Form-level `form.errors` and `getErrors(form)` contain only root-level errors. Use the deep-error methods when descendant field errors are needed. The singular variants `getDeepError` and `getDeepErrorEntry` stop at the first field with errors, which is useful for showing a single message for a nested structure.

### Reading Dirty State

```ts
import {
  getDirtyInput,
  getDirtyPaths,
  isDirty,
  pickDirty,
} from "@formisch/react";

// Raw dirty form input, or undefined when nothing is dirty
const dirtyInput = getDirtyInput(form);

// Paths of dirty fields (arrays are treated as atomic values)
const dirtyPaths = getDirtyPaths(form);

// Cheap boolean check when the dirty values are not needed
const hasChanges = isDirty(form);

// Boolean check scoped to a field and its descendants
const emailChanged = isDirty(form, { path: ["email"] });

// In a submit handler, keep transformed output only where fields are dirty
const dirtyOutput = pickDirty(form, { from: output });
```

`getDirtyInput` returns raw form input. `pickDirty` applies the form's dirty mask to a supplied value, which is useful for validated and transformed submit output.

The sibling methods `isTouched`, `isEdited`, and `isValid` follow the same pattern as `isDirty`. Each checks the entire form when called without a config, or a specific field and its descendants when called with a `path`.

### Setting Values

```ts
import { setInput, setErrors, reset } from "@formisch/react";

// Set field value (updates current input, not initial)
setInput(form, { path: ["email"], input: "new@example.com" });

// Set field errors manually
setErrors(form, { path: ["email"], errors: ["Email already taken"] });

// Clear errors
setErrors(form, { path: ["email"], errors: null });

// Reset entire form
reset(form);

// Reset a single field
reset(form, { path: ["email"] });

// Reset with new initial values
reset(form, {
  initialInput: { email: "", password: "" },
});

// Reset but keep current input
reset(form, {
  initialInput: newServerData,
  keepInput: true,
});
```

`reset` also accepts the flags `keepTouched`, `keepEdited`, and `keepErrors`. The form-level reset additionally accepts `keepSubmitted`. All flags default to `false`.

### Form Control

```ts
import { validate, focus, submit, handleSubmit } from "@formisch/react";

// Validate form manually (returns a Promise of a Valibot SafeParseResult)
const result = await validate(form);
if (result.success) {
  console.log(result.output);
} else {
  console.log(result.issues);
}

// Validate and focus first error field
await validate(form, { shouldFocus: true });

// Focus a specific field
focus(form, { path: ["email"] });

// Programmatically submit form
submit(form);

// Create submit handler for external buttons
const onExternalSubmit = handleSubmit(form, (output) => {
  console.log(output);
});
```

`submit` requires a registered DOM form and is not exported by `@formisch/react-native`. In React Native and in layouts without a `<form>` element, call the function returned by `handleSubmit` instead.

## Field Arrays

For dynamic lists of fields, use `FieldArray` with array manipulation methods.

The field array store exposes `path`, `items` (stable item IDs for use as keys), `errors`, `isTouched`, `isEdited`, `isDirty`, and `isValid`.

### Schema

```ts
const TodoSchema = 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),
  ),
});
```

### React Example

```tsx
import {
  Field,
  FieldArray,
  Form,
  useForm,
  insert,
  remove,
  move,
  swap,
} from "@formisch/react";

export default function TodoPage() {
  const todoForm = useForm({
    schema: TodoSchema,
    initialInput: {
      heading: "",
      todos: [{ label: "", deadline: "" }],
    },
  });

  return (
    <Form of={todoForm} onSubmit={(output) => console.log(output)}>
      <Field of={todoForm} path={["heading"]}>
        {(fiel