# Dirty fields

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

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<User>();

  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<typeof UserSchema> = 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<typeof UserSchema> = 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<typeof Schema> = 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<typeof Schema> = 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<typeof Schema> = 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.
