# Formisch
> The lightweight, schema-first and fully type-safe form library for Angular, Preact, Qwik, React, React Native, Solid, Svelte and Vue.
## Get started
### Introduction
Formisch is a schema-based, headless form library for React Native. 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 starting at 2.5 kB
- Schema-based validation with Valibot
- Type safety with autocompletion in editor
- Open source and fully tested with 100 % coverage
- It's fast – re-renders only if necessary
- Minimal, readable and well thought out API
- Supports native `TextInput` fields
#### Example
Every form starts with the [`useForm`](/react-native/api/useForm.md) hook. It initializes your form's store based on the provided Valibot schema and infers its types. Unlike the DOM frameworks, React Native has no native form element or submit event, so there is no `
` component and submission is triggered explicitly with [`handleSubmit`](/methods/api/handleSubmit.md), for example from a button's `onPress` or a text input's `onSubmitEditing` handler. You can access the state of a field with the [`useField`](/react-native/api/useField.md) hook or the [``](/react-native/api/Field.md) component to connect your `TextInput`.
```tsx
import { Field, handleSubmit, useForm } from '@formisch/react-native';
import { Button, Text, 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 (
{(field) => (
{field.errors && {field.errors[0]}}
)}
{(field) => (
{field.errors && {field.errors[0]}}
)}
);
}
```
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), [`swap`](/methods/api/swap.md) and [`validate`](/methods/api/validate.md). These methods allow you to control the form programmatically.
#### 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 library is built, giving you native performance for any UI update. A modular methods API keeps bundles starting at just ~2.5 kB 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 React Hook Form and TanStack Form, see the [comparison guide](/react-native/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,
// ...
}
}
```
#### 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/react-native # npm
yarn add @formisch/react-native # yarn
pnpm add @formisch/react-native # pnpm
bun add @formisch/react-native # bun
deno add npm:@formisch/react-native # deno
```
Then you can import it into any JavaScript or TypeScript file.
```ts
import { … } from '@formisch/react-native';
```
#### 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 React Native 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-react-native.txt`](/llms-react-native.txt) contains a table of contents with links to React Native-related files
- [`llms-react-native-full.txt`](/llms-react-native-full.txt) contains the Markdown content of the entire React Native docs
- [`llms-react-native-guides.txt`](/llms-react-native-guides.txt) contains the Markdown content of the React Native guides
- [`llms-react-native-api.txt`](/llms-react-native-api.txt) contains the Markdown content of the React Native 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. `/react-native/guides/installation/` becomes [`/react-native/guides/installation.md`](/react-native/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 React Native. The two most common alternatives, [React Hook Form](https://react-hook-form.com) and [TanStack Form](https://tanstack.com/form/latest), also run in React Native. This page is meant as a quick reference for picking the right tool. For a deeper explanation of the architectural differences and the reasoning behind each row of the table below, see our [long-form comparison article](/blog/react-form-library-comparison.md) — it was written for React on the web, but the architecture it describes applies equally to React Native.
#### At a glance
| | **Formisch** | React Hook Form | TanStack Form |
| ---------------------- | -------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------- |
| Type source | Inferred from schema | Generic you declare | Inferred from `defaultValues` |
| Validation location | Defined in schema | Per-field or resolver | Per-validator config |
| Validation timing | Form-wide `validate` / `revalidate` | Form-wide `mode` option | Per-validator trigger |
| Async validation | Built-in via schema | Manual loading state | Built-in `isValidating` |
| Re-render scope | Automatic per signal | Manual via `watch` / `useFormState` | Automatic per subscription |
| Schema libraries | Valibot | Any via resolvers | Standard Schema |
| Bundle size (min+gzip) | From ~2.5 kB | ~12 kB | ~15 kB |
| Framework support | Angular, React, React Native, Preact, Solid, Svelte, Vue, Qwik | React, React Native | React, React Native, 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 re-renders.
#### 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 separate TypeScript generic to declare, no `defaultValues` object to keep aligned with the schema, no resolver to configure. When the schema changes, every part of the form follows — at compile time and at runtime.
**The smallest bundle, by a wide margin.** Formisch starts at ~2.5 kB and grows only as you import additional methods like `focus`, `getInput`, and `reset`. That is several times smaller than the alternatives in the table above — and it stays that way because the core is intentionally small and the library is fully tree-shakable, so methods you don't import don't end up in your bundle.
**Type safety that stays fast.** Types flow from the schema through every API, including deeply nested paths and field arrays. The inference is structured to keep TypeScript editor performance from degrading as schemas grow — which matters in large codebases where heavily-generic form libraries become a friction point.
#### Which library should you use?
**React Hook Form** is a mature, well-documented choice with a large community and years of production usage. In React Native there are no DOM inputs to `register`, so each `TextInput` is wired up through its `Controller` component, which adds a wrapper per field. For simple to moderately complex forms, it remains a reasonable default. The tradeoffs only become visible as forms grow: types and schema drift out of sync, validation rules spread across components, and re-render scope becomes your responsibility.
**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 is already invested in the TanStack ecosystem 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. The schema-first design means there is a single source of truth for types, runtime validation, and form structure, so there is less to keep aligned over time. The main consideration is that Formisch currently supports only [Valibot](https://valibot.dev) as the schema library.
#### Migrating to Formisch
Migrating to Formisch is a genuine rewrite of the form layer, not a drop-in replacement. The mental model is different: instead of starting from a component and attaching form behavior to it, you start from a schema and build the component around it. The libraries can coexist in the same application, so you can migrate one form at a time.
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 React Hook Form](/react-native/guides/migrate-from-react-hook-form.md) and [migrate from TanStack Form](/react-native/guides/migrate-from-tanstack-form.md).
#### Next steps
If you have decided that Formisch is a good fit, install it via the [installation](/react-native/guides/installation.md) guide and start building by [defining your form](/react-native/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 [`useForm`](/react-native/api/useForm.md), TypeScript types are automatically inferred from your schema, giving you full autocompletion and type safety throughout your form 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.')
),
});
```
##### 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 screen.
#### 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](/react-native/guides/create-your-form.md) guide to learn how to initialize your form with [`useForm`](/react-native/api/useForm.md).
### Create your form
Formisch consists of hooks, components and methods. To create a form you use the [`useForm`](/react-native/api/useForm.md) hook.
#### Form hook
The [`useForm`](/react-native/api/useForm.md) hook initializes and returns the store of your form. The store contains the state of the form and can be used with other Formisch hooks, components and methods to build your form.
Unlike the web, React Native has no native form element or submit event. Therefore, submission is triggered explicitly with the [`handleSubmit`](/methods/api/handleSubmit.md) method — for example, from a button's `onPress` handler. You will learn more about this in the [handle submission](/react-native/guides/handle-submission.md) guide.
```tsx
import { handleSubmit, useForm } from '@formisch/react-native';
import { Button, 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 (
{/* Form fields will go here */}
);
}
```
##### Configuration options
The [`useForm`](/react-native/api/useForm.md) hook accepts a configuration object with the following options:
- `schema`: Your Valibot schema that defines the form
- `initialInput`: Initial values for your form fields (optional)
- `emptyInput`: The empty value each field type starts at when a required field has no initial input (optional, defaults to `{ string: '' }`)
- `validate`: When validation first occurs (optional, defaults to `'submit'`)
- `revalidate`: When revalidation occurs after initial validation (optional, defaults to `'input'`)
```tsx
const loginForm = useForm({
schema: LoginSchema,
initialInput: {
email: 'user@example.com',
},
validate: 'initial',
revalidate: 'input',
});
```
Formisch tracks two inputs for every field: the **initial input** (baseline for dirty tracking) and the **current input** (what the user is editing). In many apps, the initial input represents the server state while the current input represents the client state.
`isDirty` becomes `true` when a field's current input differs from its initial input. Use [`setInput`](/methods/api/setInput.md) to update the current input (client state), and use [`reset`](/methods/api/reset.md) to update the initial input (baseline) when your server data changes or is refreshed.
##### Empty input
By default, a required string field starts as an empty string (`''`) instead of `undefined`, matching an empty text input. This way an empty field shows the validation message you defined for it (for example from `v.nonEmpty()`) without you setting an `initialInput` for every field.
You can configure the empty value per field type, or opt out by setting a type to `undefined`:
```tsx
const loginForm = useForm({
schema: LoginSchema,
emptyInput: {
string: '', // the default
number: 0, // required numbers start at 0 instead of undefined
boolean: false, // switches start turned off
},
});
```
Optional and nullable fields are never affected and keep starting as `undefined`, since they accept it. The supported types are `string`, `number`, `boolean` and `date`, and the default is `{ string: '' }`.
#### Multiple forms
When a screen contains multiple forms, you can create separate form stores for each one:
```tsx
import { useForm } from '@formisch/react-native';
import { 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)),
});
const RegisterSchema = v.object({
username: v.pipe(v.string(), v.minLength(3)),
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
export default function AuthScreen() {
const loginForm = useForm({ schema: LoginSchema });
const registerForm = useForm({ schema: RegisterSchema });
return (
{/* Login form fields */}{/* Register form fields */}
);
}
```
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](/react-native/guides/add-form-fields.md) guide to learn how to connect your input components to the form using the [`Field`](/react-native/api/Field.md) component.
### Add form fields
To add a field to your form, you can use the [`Field`](/react-native/api/Field.md) component or the [`useField`](/react-native/api/useField.md) hook. Both are headless and provide access to field state for building your form UI.
#### Field component
The [`Field`](/react-native/api/Field.md) component has two mandatory properties: `of` which accepts the form store, and `path` which specifies which field to connect. If you use TypeScript, you get full autocompletion for the path based on your schema.
##### Children prop
As a child, you pass a function to the [`Field`](/react-native/api/Field.md) component that returns JSX. The function receives the field store as its parameter, which includes the current value, error messages, and props to spread onto your `TextInput` component.
```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 (
{(field) => (
)}
{(field) => (
)}
);
}
```
> **Important:** Text inputs are controlled. Always pass `value={field.input}` so that initial values from `initialInput` and programmatic updates via methods like [`setInput`](/methods/api/setInput.md) or [`reset`](/methods/api/reset.md) are reflected in the UI.
> **Important:** The `onChangeText` handler in `field.props` receives the value as a string. Fields with a non-string schema like `v.number()` or `v.date()` therefore need to use `field.onChange` to store the value with the correct type. See the [controlled fields](/react-native/guides/controlled-fields.md) guide to learn more.
##### Headless design
The [`Field`](/react-native/api/Field.md) component 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 React Native's built-in components, custom components or an external UI library.
##### Path array
The `path` property 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:
```tsx
{(field) => }
```
For nested fields, the path reflects the structure of your schema:
```tsx
// For a schema like: v.object({ user: v.object({ email: v.string() }) })
{(field) => }
```
##### Type safety
The API design of the [`Field`](/react-native/api/Field.md) component results in a fully type-safe form. For example, if you change your schema, TypeScript will immediately alert you if the path is invalid. The field state is also fully typed based on your schema, giving you autocompletion for properties like `field.input`.
#### useField hook
For very complex forms where you create individual components for each form field, Formisch provides the [`useField`](/react-native/api/useField.md) hook. It allows you to access the field state directly within your component logic.
```tsx
import { useField } from '@formisch/react-native';
import type { FormStore } from '@formisch/react-native';
import { Text, TextInput, View } from 'react-native';
import * as v from 'valibot';
type EmailInputProps = {
form: FormStore>;
};
function EmailInput(props: EmailInputProps) {
const field = useField(props.form, { path: ['email'] });
return (
{field.errors && {field.errors[0]}}
);
}
```
##### When to use which
- **Use [`Field`](/react-native/api/Field.md) component**: When defining multiple fields in the same component. It ensures you don't accidentally access the wrong field store.
- **Use [`useField`](/react-native/api/useField.md) hook**: When creating field components for single fields. It allows you to access field state in your component logic.
The [`Field`](/react-native/api/Field.md) component is essentially a thin wrapper around [`useField`](/react-native/api/useField.md) that allows you to access the field state within JSX code.
#### Field store
The field store provides access to the following properties. They are plain values, but because Formisch wires into React's rendering, your components re-render when they change.
- `props`: Props to spread onto your `TextInput` component (includes the event handlers and the ref callback to register the field).
- `input`: The current input value of the field.
- `errors`: The current array of error messages if validation fails.
- `isTouched`: Whether the field has been touched.
- `isEdited`: Whether the field's value has been changed.
- `isDirty`: Whether the current input differs from the initial input.
- `isValid`: Whether the field passes all validation rules.
- `onChange`: Sets the field input value programmatically. Use this for components that don't render a text input (e.g., `Switch`, sliders or picker components).
#### Next steps
Now that you know how to add fields to your form, continue to the [input components](/react-native/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/react-native/components).
#### Why input components?
Currently, your fields might look something like this:
```tsx
{(field) => (
Email
{field.errors && {field.errors[0]}}
)}
```
If styling 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 `TextInput` component so that the code ends up looking like this:
```tsx
{(field) => (
)}
```
#### Create an input component
In the first step, you create a new file for the `TextInput` component and, if you use TypeScript, define its properties. By extending `FieldElementProps`, your component accepts the props of the field store, which include the event handlers and the ref callback.
```tsx
import type { FieldElementProps } from '@formisch/react-native';
interface TextInputProps extends FieldElementProps {
type: 'text' | 'email' | 'tel' | 'password' | 'url';
label?: string;
placeholder?: string;
input: string | undefined;
errors: [string, ...string[]] | null;
required?: boolean;
}
```
##### Component function
In the next step, add the component function to the file. We can destructure props directly and spread the remaining props. Since the component wraps React Native's own `TextInput`, we import it under the alias `RNTextInput`.
```tsx
import type { FieldElementProps } from '@formisch/react-native';
import { TextInput as RNTextInput, Text, View } from 'react-native';
interface TextInputProps extends FieldElementProps {
/* ... */
}
export function TextInput({
label,
input,
errors,
type,
required,
...props
}: TextInputProps) {
// Component implementation
}
```
##### JSX code
After that, you can add the JSX code to the return statement. Since React Native has no input types, we map the `type` prop to the matching `keyboardType` and use `secureTextEntry` for passwords. React Native has no equivalent of the `aria-invalid` attribute, so the visible error message below the input is what communicates the invalid state. It also has no way to associate a separate label element with an input, so the label text is passed to the input as its `accessibilityLabel` for screen readers.
```tsx
import type { FieldElementProps } from '@formisch/react-native';
import {
type KeyboardTypeOptions,
TextInput as RNTextInput,
Text,
View,
} from 'react-native';
interface TextInputProps extends FieldElementProps {
/* ... */
}
const KEYBOARD_TYPES: Record = {
text: 'default',
email: 'email-address',
tel: 'phone-pad',
password: 'default',
url: 'url',
};
export function TextInput({
label,
input,
errors,
type,
required,
...props
}: TextInputProps) {
return (
{label && (
{label} {required && *}
)}
{errors && {errors[0]}}
);
}
```
##### Next steps
You can now build on this code and add styling, for example. You can also follow the procedure to create other components such as a `Switch` or a picker component.
##### Final code
Below is an overview of the entire code of the `TextInput` component.
```tsx
import type { FieldElementProps } from '@formisch/react-native';
import {
type KeyboardTypeOptions,
TextInput as RNTextInput,
Text,
View,
} from 'react-native';
interface TextInputProps extends FieldElementProps {
type: 'text' | 'email' | 'tel' | 'password' | 'url';
label?: string;
placeholder?: string;
input: string | undefined;
errors: [string, ...string[]] | null;
required?: boolean;
}
const KEYBOARD_TYPES: Record = {
text: 'default',
email: 'email-address',
tel: 'phone-pad',
password: 'default',
url: 'url',
};
export function TextInput({
label,
input,
errors,
type,
required,
...props
}: TextInputProps) {
return (
{label && (
{label} {required && *}
)}
{errors && {errors[0]}}
);
}
```
#### Non-text components
The props of the field store are designed for text inputs. React Native's own non-text components — such as `Switch`, sliders or picker components — do not accept an `onChangeText` handler, so you cannot spread `field.props` onto them. Use `field.onChange` to update the value instead:
```tsx
import { Switch } from 'react-native';
{(field) => }
;
```
The `field.onChange` method updates the field value and triggers validation. Your own components can still extend `FieldElementProps` as shown above: forward the `ref` to the pressable part of the component and call the `onFocus` and `onBlur` handlers when the interaction starts and ends, so that [`focus`](/methods/api/focus.md), `isTouched` and blur validation keep working. For more details, see the [controlled fields](/react-native/guides/controlled-fields.md) guide.
#### Next steps
Now that you know how to create reusable input components, continue to the [handle submission](/react-native/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 function
React Native has no native form element or submit event. Instead, you create a submit function with the [`handleSubmit`](/methods/api/handleSubmit.md) method and call it explicitly — for example, from the `onPress` handler of a `Button` or `Pressable`. The [`handleSubmit`](/methods/api/handleSubmit.md) method takes your form store and a handler that receives the validated form values.
```tsx
import { handleSubmit, useForm } from '@formisch/react-native';
import { Button, 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, (values) => {
// Process the validated form values
console.log(values); // { email: string, password: string }
});
return (
{/* Form fields will go here */}
);
}
```
When called, the returned function validates the form and only invokes your handler if validation succeeds. Since it accepts no arguments and returns a promise, you can also call it from anywhere to trigger submission programmatically.
If you define your handler separately, you can use the [`SubmitHandler`](/core/api/SubmitHandler.md) type to ensure type safety. It automatically infers the types of the validated values from your schema. Since React Native has no submit event, the handler receives only the validated values — there is no event object.
```tsx
import type { SubmitHandler } from '@formisch/react-native';
const submitHandler: SubmitHandler = (values) => {
// Process the validated form values
console.log(values);
};
const submitForm = handleSubmit(loginForm, submitHandler);
```
##### Submit on editing
Besides a button, you can also trigger submission from the keyboard's submit key by passing the submit function to the `onSubmitEditing` prop of your last `TextInput`:
```tsx
{(field) => (
)}
```
##### Loading state
While the form is being submitted, you can use `loginForm.isSubmitting` to display a loading animation and disable the submit button:
```tsx
```
The form store also provides other reactive properties 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:
```tsx
const submitForm = handleSubmit(loginForm, async (values) => {
try {
const response = await fetch('https://example.com/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);
}
});
```
While the promise returned by your handler is pending, `loginForm.isSubmitting` stays `true`.
#### Next steps
Congratulations! You've learned the core concepts of building forms with Formisch. To learn more about advanced features, check out the [form methods](/react-native/guides/form-methods.md) guide to discover how to programmatically control your forms.
### Form methods
To retrieve the values of your form or to make changes to the form, Formisch provides you with several methods. These apply either to the entire form or to individual fields.
#### Reading values
To retrieve values from your form, you can use:
- [`getInput`](/methods/api/getInput.md): Get the current value
of a specific field
- [`getErrors`](/methods/api/getErrors.md): Get error messages
for a specific field
- [`getDeepErrors`](/methods/api/getDeepErrors.md): Get all error
messages across the entire form
Formisch plugs into React's render cycle, so reading values with these methods inside components stays reactive. When the form state changes, components that rely on these values automatically re-render without manual subscriptions.
#### Dirty state
To work with the dirty state of your form, Formisch provides three methods:
- [`getDirtyInput`](/methods/api/getDirtyInput.md): Get the dirty
parts of the form input
- [`getDirtyPaths`](/methods/api/getDirtyPaths.md): Get the paths
of dirty fields
- [`pickDirty`](/methods/api/pickDirty.md): Filter an
externally-supplied value down to its dirty parts using the form's dirty mask
See the [dirty fields](/react-native/guides/dirty-fields.md) guide for when to reach for each.
#### Setting values
To manually update form values or errors, use:
- [`setInput`](/methods/api/setInput.md): Manually update the
value of a specific field
- [`setErrors`](/methods/api/setErrors.md): Manually set error
messages for a specific field
> When you have access to a field store (from [`useField`](/react-native/api/useField.md) or [`Field`](/react-native/api/Field.md)), you can use `field.onChange(value)` to update the value directly. This is especially useful for components that don't render a text input, such as `Switch`, sliders or picker components.
> If you need to update the form because the initial data has changed (e.g., remote data was refreshed), use [`reset`](/methods/api/reset.md) with a new `initialInput` instead of [`setInput`](/methods/api/setInput.md). The [`reset`](/methods/api/reset.md) method properly reinitializes the form state, while [`setInput`](/methods/api/setInput.md) only changes the current input values without updating the initial state.
#### Form control
To control the form programmatically, use:
- [`handleSubmit`](/methods/api/handleSubmit.md): Create a submit
function that validates the form and calls your handler on success
- [`reset`](/methods/api/reset.md): Reset the form to its initial
state or update initial values
- [`validate`](/methods/api/validate.md): Manually trigger
validation of the entire form
- [`focus`](/methods/api/focus.md): Focus on a specific field
Since React Native has no native submit event, calling the function returned by [`handleSubmit`](/methods/api/handleSubmit.md) is also the way to trigger submission programmatically. See the [handle submission](/react-native/guides/handle-submission.md) guide to learn more.
#### Array operations
For working with field arrays, Formisch provides:
- [`insert`](/methods/api/insert.md): Insert a new item into a
field array
- [`remove`](/methods/api/remove.md): Remove an item from a field
array
- [`move`](/methods/api/move.md): Move an item to a different
position in a field array
- [`swap`](/methods/api/swap.md): Swap two items in a field array
- [`replace`](/methods/api/replace.md): Replace an item in a
field array
#### API design
All methods in Formisch follow a consistent API pattern: the first parameter is always the form store, and the second parameter (if necessary) is always a config object. This design makes the API flexible and consistent across all methods. Once you understand this pattern, you basically understand the entire API design.
Here are some examples:
```tsx
// Get the value of a field
const emailInput = getInput(loginForm, { path: ['email'] });
// Reset the form with new initial values
reset(loginForm, { initialInput: { email: '', password: '' } });
// Move an item in a field array
move(loginForm, { path: ['items'], from: 0, to: 3 });
```
#### API reference
You can find detailed documentation for each method in our [API reference](/react-native/api/).
## Advanced guides
### Validation
Formisch validates your form against your Valibot schema — the same one that defines its types. This guide explains how validation runs under the hood, how the `validate` and `revalidate` config control its timing, how to display errors only when they are helpful, and how to react to asynchronous validation with `isValidating`.
#### How validation works
Whenever validation is triggered, Formisch parses the **entire form** against your schema in a single pass. It then distributes the resulting issues to the individual fields: every field with an issue receives its error messages, and every field without one is cleared. A field is valid when it has no errors.
> Validation always runs against the whole schema, even when a single field triggers it. This keeps cross-field rules (like a `password` and `confirmPassword` that must match) correct without any extra wiring. The result is still stored per field, so each `` only re-renders when its own errors change.
Because Formisch parses with Valibot's asynchronous API, schemas with async checks (for example, a server-side uniqueness check) work out of the box. While such a check is in flight, the form's `isValidating` state is `true` (see [below](#validating-state)).
#### When validation runs
Two config options control the timing of validation:
- `validate`: when a field is validated for the **first** time. Defaults to `'submit'`.
- `revalidate`: when a field is validated **again**, once it already has an error or the form has been submitted. Defaults to `'input'`.
Both accept the following modes:
- `'initial'`: immediately, when the form is created (only valid for `validate`, not `revalidate`).
- `'touch'`: when the field is first focused.
- `'input'`: on every change to the field's value.
- `'change'`: same timing as `'input'` in React Native, since a field only has a single change event (`onChangeText` for text inputs or `field.onChange` for everything else).
- `'blur'`: when the field loses focus.
- `'submit'`: only when the form is submitted.
```tsx
const loginForm = useForm({
schema: LoginSchema,
validate: 'blur',
revalidate: 'input',
});
```
The split between `validate` and `revalidate` is what makes good defaults possible. With the default `validate: 'submit'` and `revalidate: 'input'`, a field stays quiet until the user submits, and only then does it start correcting itself live as the user fixes it. The configuration above (`validate: 'blur'`) is a gentler variant: a field is first checked when the user leaves it, then re-checked on every keystroke once it has an error.
#### Showing errors at the right time
`validate` and `revalidate` control **when validation runs** — but not **when errors are shown**. After a validation pass, every invalid field has its `errors` populated, and it is up to you to decide which ones to render.
Showing every error the moment it appears can overwhelm the user, while showing them only after submit forces them to scroll back and fix fields they have already filled out. A balanced approach is to reveal a field's error once the user has actually changed it, and to reveal all remaining errors after the first submit attempt.
Each field exposes several state flags to drive this decision:
- `isTouched`: the field has been focused or changed.
- `isEdited`: the field's value has been changed (but not merely focused), and stays `true` even if the value is changed back to its initial value.
- `isDirty`: the field's current value differs from its initial value.
The form additionally exposes `isSubmitted`, which becomes `true` after the first submit attempt. Combining `isEdited` with `isSubmitted` gives the balanced behavior described above:
```tsx
import { Field, handleSubmit, useForm } from '@formisch/react-native';
import { Button, Text, 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,
validate: 'change',
});
const submitForm = handleSubmit(loginForm, (output) => console.log(output));
return (
{(field) => (
{(field.isEdited || loginForm.isSubmitted) && field.errors && (
{field.errors[0]}
)}
)}
);
}
```
`isEdited` is the right flag for this. `isTouched` would reveal the error as soon as the user moves 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 `TextInput` from the [input components](/react-native/guides/input-components.md) guide, so the `isEdited || isSubmitted` logic lives in exactly one place:
```tsx
import { Text } from 'react-native';
interface FieldErrorProps {
errors: [string, ...string[]] | null;
isEdited: boolean;
isSubmitted: boolean;
}
export function FieldError({ errors, isEdited, isSubmitted }: FieldErrorProps) {
if (!errors || (!isEdited && !isSubmitted)) {
return null;
}
return {errors[0]};
}
```
Each field then stays focused on its input:
```tsx
{(field) => (
)}
```
#### 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.
```tsx
import { handleSubmit, useForm } from '@formisch/react-native';
import { Button, View } from 'react-native';
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.')
),
});
export default function SignUpScreen() {
const signUpForm = useForm({
schema: SignUpSchema,
validate: 'blur',
});
const submitForm = handleSubmit(signUpForm, (output) => console.log(output));
return (
{/* fields */}
);
}
```
`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](/react-native/guides/handle-submission.md) guide. For working with the fields a user has changed, see the [dirty fields](/react-native/guides/dirty-fields.md) guide.
### Special inputs
React Native has no built-in form elements beyond text entry — there are no native checkboxes, dropdown selects or file inputs. Text is entered through `TextInput`, which connects to a field by spreading `field.props`. Every other kind of input — switches, sliders, selection lists, pickers — is wired up manually: read the current value from `field.input` and write it back with `field.onChange`. This guide covers the most common cases.
> In our [playground](/playground/special/) you can take a look at such fields and test them out.
Because `field.onChange` accepts whatever type your schema defines for the field, this pattern is not limited to strings. A switch stores a boolean, a slider stores a number, and a multi-select stores an array — no string conversion involved.
#### Switch
A boolean field is represented by React Native's `Switch` component. Pass the field's input as `value` and forward `onValueChange` to `field.onChange`:
```tsx
{(field) => (
Yes, I want cookies
)}
```
The corresponding schema entry is a plain boolean, for example `v.optional(v.boolean(), false)`.
#### Slider
Sliders such as the community package `@react-native-community/slider` emit real numbers. Unlike a DOM range input, which reports its value as a string, no conversion is needed — define the field as `v.number()` and pass the emitted value straight to `field.onChange`:
```tsx
import Slider from '@react-native-community/slider';
{(field) => (
)}
;
```
#### Checkbox group
A group of checkboxes represents an array of strings. Since React Native has no native checkbox, you build one yourself — typically a `Pressable` that toggles its state — or use one from a component library. The wiring is always the same: a checkbox is checked when its value is included in `field.input`, and toggling it calls `field.onChange` with a new array that adds or removes the value.
```tsx
{[
{ label: 'Bananas', value: 'bananas' },
{ label: 'Apples', value: 'apples' },
{ label: 'Grapes', value: 'grapes' },
].map(({ label, value }) => (
{(field) => (
field.onChange(
checked
? [...field.input, value]
: field.input.filter((item) => item !== value)
)
}
/>
)}
))}
```
For a single boolean checkbox, skip the array logic and pass `field.onChange` directly, exactly like the `Switch` example above.
#### Radio group
A group of radio buttons stores a single string. Each option is selected when it equals `field.input`, and pressing an option simply calls `field.onChange` with its value:
```tsx
{(field) => (
{[
{ label: 'Red', value: 'red' },
{ label: 'Green', value: 'green' },
{ label: 'Blue', value: 'blue' },
].map(({ label, value }) => (
field.onChange(value)}
/>
))}
)}
```
#### Select
Selection lists — whether you build them with a modal, a bottom sheet, or a picker component — follow the same two patterns. A single select stores a string:
```tsx
{(field) => (
)}
```
A multi-select stores an array of strings. Toggle the pressed value in or out of the array, just like the checkbox group:
```tsx
{(field) => (
```
#### File
React Native has no `File` class and no file input element. Instead, document and image pickers such as `expo-document-picker` or `expo-image-picker` return plain asset objects that describe the selected file by its URI. You store these objects directly in the form and describe their shape in your schema. The schema below matches the assets of `expo-document-picker`; other pickers report different metadata — `expo-image-picker`, for example, returns `fileName` and `fileSize` instead of `name` and `size` — so map their assets to the shape your schema expects:
```tsx
import * as v from 'valibot';
const FileAssetSchema = v.object({
uri: v.string(),
name: v.string(),
mimeType: v.optional(v.string()),
size: v.optional(v.number()),
});
const FormSchema = v.object({
avatar: v.optional(FileAssetSchema),
documents: v.array(FileAssetSchema),
});
```
To connect a picker, open it from a press handler and pass the resulting asset to `field.onChange`:
```tsx
import * as DocumentPicker from 'expo-document-picker';
{(field) => (
{
const result = await DocumentPicker.getDocumentAsync();
if (!result.canceled) {
const { uri, name, mimeType, size } = result.assets[0];
field.onChange({ uri, name, mimeType, size });
}
}}
>
{field.input?.name ?? 'Select file'}
)}
;
```
For multiple files, open the picker with `multiple: true` and pass the mapped array of assets to `field.onChange`. The field then holds `FileAsset[]`, and your submit handler can upload the selected files by their URIs.
> Components like these never focus or blur on their own. When you extract them into reusable input components as described in the [input components](/react-native/guides/input-components.md) guide, accept `field.props` and call its `onFocus` handler on the first interaction so that `isTouched` and the `'touch'` validation mode keep working. If you also validate on `'blur'`, call its `onBlur` handler when the interaction ends.
### Controlled fields
In React Native, all form fields are controlled. There is no browser that keeps its own input state on a DOM element — a `TextInput` displays exactly what you pass as `value` and reports changes through `onChangeText`. Formisch embraces this: the form store holds every field value, and `field.input` is the single source of truth.
#### Why controlled?
Because the value lives in the form store, everything Formisch does is immediately reflected in the UI: initial values appear on the first render, [`setInput`](/methods/api/setInput.md) and [`reset`](/methods/api/reset.md) update the visible text, and array operations like [`insert`](/methods/api/insert.md) or [`move`](/methods/api/move.md) rearrange the displayed values automatically. There is no extra wiring to opt in to — you get it by always passing `value`.
##### Text input example
For a text field, spread `field.props` and pass the field's input as `value`:
```tsx
{(field) => }
```
Spreading `field.props` registers the `TextInput` instance via `ref` (which is what makes [`focus`](/methods/api/focus.md) work) and connects the `onFocus`, `onBlur` and `onChangeText` handlers that keep the store in sync and trigger validation. Passing `value={field.input}` closes the loop: the input always displays the store's current value.
##### Setting values programmatically
To change a field's value from outside the input — for example from a button press or after a network request — use [`setInput`](/methods/api/setInput.md), or call `field.onChange` when you are inside the field's render function. Both update the store and trigger validation, and the controlled `TextInput` re-renders with the new value automatically.
##### No exception for files
Unlike the web, where a file input cannot be controlled, file fields in React Native are controlled like any other field. Document and image pickers return plain asset objects that you store with `field.onChange`. See the [special inputs](/react-native/guides/special-inputs.md) guide for a full example.
#### Numbers and dates
A `TextInput` only reads and writes strings. If a field should end up as a number or a `Date`, you have two options.
##### Transform in the schema
Keep the field a string in the form and let your schema transform it during validation. The user types into a regular `TextInput`, `field.input` stays a string, and the validated output has the transformed type:
```tsx
import * as v from 'valibot';
const ProfileSchema = v.object({
age: v.pipe(v.string(), v.decimal(), v.transform(Number)),
});
{(field) => (
)}
;
```
##### Use a component that emits the right type
Components that are not text-based emit real values, so no conversion is needed. A slider emits numbers and a date picker emits `Date` objects — define the schema with `v.number()` or `v.date()` and store the emitted value with `field.onChange`:
```tsx
import DateTimePicker from '@react-native-community/datetimepicker';
{(field) => (
field.onChange(date)}
/>
)}
;
```
#### Custom inputs and component libraries
The `onChangeText` handler in `field.props` is designed for text inputs. For components that report changes differently — sliders, switches, pickers, rich text editors, or components from a UI library — use `field.onChange` to set the value:
```tsx
import { ColorPicker } from 'some-component-library';
{(field) => (
)}
;
```
The `field.onChange` method updates the field value and triggers validation, just like typing into a connected `TextInput` would.
#### Next steps
Now that you understand controlled fields, you can explore more advanced topics like [nested fields](/react-native/guides/nested-fields.md) and [field arrays](/react-native/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`.
```tsx
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 for the `path` property to refer to the nested field. The array represents the path through the nested structure.
```tsx
{(field) => }
```
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:
```tsx
const ProfileSchema = v.object({
user: v.object({
address: v.object({
street: v.string(),
city: v.string(),
country: v.string(),
}),
}),
});
// Access deeply nested field
{(field) => }
;
```
#### 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.
### 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.
```tsx
import {
getDirtyInput,
handleSubmit,
type SubmitHandler,
useForm,
} from '@formisch/react-native';
import { Button, View } from 'react-native';
import * as v from 'valibot';
const UserSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty()),
email: v.pipe(v.string(), v.email()),
});
export default function EditProfileScreen({ user }) {
const profileForm = useForm({
schema: UserSchema,
initialInput: user,
});
const onSubmit: SubmitHandler = async () => {
const dirty = getDirtyInput(profileForm);
if (dirty) {
await api.patchUser(user.id, dirty);
}
};
const submitForm = handleSubmit(profileForm, onSubmit);
return (
{/* fields */}
);
}
```
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.
```tsx
import { getDirtyPaths } from '@formisch/react-native';
const dirtyPaths = getDirtyPaths(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.
```tsx
import { pickDirty, type SubmitHandler } from '@formisch/react-native';
const onSubmit: SubmitHandler = async (output) => {
const dirty = pickDirty(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:
```tsx
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.
```tsx
import { pickDirty, type SubmitHandler } from '@formisch/react-native';
const onSubmit: SubmitHandler = async (output) => {
// output.age is a number — pickDirty preserves that.
const dirty = pickDirty(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 `form` is your form store (the result of [`useForm`](/react-native/api/useForm.md)).
##### Skip submission when nothing changed
The form's `isDirty` flag 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.
```tsx
import type { SubmitHandler } from '@formisch/react-native';
const onSubmit: SubmitHandler = async (output) => {
if (!form.isDirty) {
return;
}
await api.update(output);
};
```
##### Send only the dirty raw input
```tsx
import { getDirtyInput } from '@formisch/react-native';
const dirty = getDirtyInput(form);
if (dirty) {
await api.patch(dirty);
}
```
##### Send only the dirty validated output
```tsx
import { pickDirty, type SubmitHandler } from '@formisch/react-native';
const onSubmit: SubmitHandler = async (output) => {
const dirty = pickDirty(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 tight reactive loops 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 [`FieldArray`](/react-native/api/FieldArray.md) component 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 it out.
#### Create a field array
##### Schema definition
In the following example we create a field array for a todo form with the following schema:
```tsx
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 component
To dynamically generate the form fields for the todos, you use the [`FieldArray`](/react-native/api/FieldArray.md) component in combination with React's `Array.map()`. The field array provides an `items` array of unique string identifiers which are used to detect when an item is added, moved, or removed.
```tsx
import {
Field,
FieldArray,
handleSubmit,
useForm,
} from '@formisch/react-native';
import { Button, Text, TextInput, View } from 'react-native';
export default function TodosScreen() {
const todoForm = useForm({
schema: TodoFormSchema,
initialInput: {
heading: '',
todos: [{ label: '', deadline: '' }],
},
});
const submitForm = handleSubmit(todoForm, (output) => console.log(output));
return (
{(field) => (
{field.errors && {field.errors[0]}}
)}
{(fieldArray) => (
{fieldArray.items.map((item, index) => (
{(field) => (
{field.errors && {field.errors[0]}}
)}
{(field) => (
{field.errors && {field.errors[0]}}
)}
))}
{fieldArray.errors && {fieldArray.errors[0]}}
)}
);
}
```
##### Path array with index
As with [nested fields](/react-native/guides/nested-fields.md), you use an array for the `path` property. The key difference is that you include the index from the `map()` function to specify which array item you're referencing. This ensures the paths update correctly when items are added, moved, or removed.
```tsx
{(field) => }
```
#### 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.
##### Insert method
Add a new item to the array:
```tsx
import { insert } from '@formisch/react-native';