form.state
A form's state as a pure reducer: values, touched fields, validator errors and the submit, for useReducer.
TypeScript only A Python or Rust project cannot use it.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 40 tests, run in TypeScript.initialFormState 9 · formReducer 22 · visibleErrors 9
What it does
A form's state as plain data and a pure reducer: the values, which fields the person has left, the errors a validator found, and where the submit is. It is three functions:
- `initialFormState(values)` builds a fresh state from the starting values. - `formReducer(state, action)` answers the next state after one action. - `visibleErrors(state)` answers the messages to show right now.
The functions
A group: 3 functions that work together, each in its own file, each pinned by its own tests. A project can install only the ones it calls.
- initialFormState (values: map<string>) -> FormState
- formReducer (state: FormState, action: FormAction) -> FormState
- visibleErrors (state: FormState) -> map<string>
The types it declares, generated into your project
export type FormActionType = "change" | "blur" | "validated" | "submit" | "submitted" | "failed" | "reset";
export type FormStatus = "editing" | "submitting" | "submitted";
/** One thing that happened to the form. */
export interface FormAction {
readonly type: FormActionType;
/** the field, for change and blur */
readonly name?: string;
/** the new value, for change */
readonly value?: string;
/** field name to message: for validated (a validate-* capability's fields), and for failed (the server's) */
readonly errors?: Readonly<Record<string, string>>;
}
/** Everything a form needs to render, as plain data. */
export interface FormState {
/** each field's current value, in the form's order */
readonly values: Readonly<Record<string, string>>;
/** the values reset goes back to */
readonly initial: Readonly<Record<string, string>>;
/** fields the person has left, or every field once they have tried to submit; in the form's order */
readonly touched: readonly string[];
/** the latest validation: field name to message */
readonly errors: Readonly<Record<string, string>>;
/** how many times the person has tried to submit */
readonly submitCount: number;
/** submitting while a valid submit is in flight, submitted once it succeeded */
readonly status: FormStatus;
}
Once installed, your code imports each one from the group's module.
initialFormState throws on bad input 9 tests
export function initialFormState(values: Readonly<Record<string, string>>): FormState
| values | map<string> | every field's starting value, in the order the form shows them |
| returns | FormState | nothing touched, no errors, never submitted |
For example
initialFormState(email , password , name )→ values …, initial …, touched , errors …, submit count 0, status editing a sign-up form starts empty, untouched and unsubmittedinitialFormState(email ada@example.com, name Ada)→ values …, initial …, touched , errors …, submit count 0, status editing starting values are kept, and are what reset returns toinitialFormState(surname , given , dob )→ values …, initial …, touched , errors …, submit count 0, status editing the fields keep the order given, not alphabetical
import { initialFormState } from "#fune/form.state@^1";
import type { FormState } from "./form_state_types.ts";
/**
* A fresh form: the values as given, nothing touched, no errors, never
* submitted. The values' order is the form's order, which visibleErrors
* keeps so an error summary lists problems top to bottom.
*/
export function initialFormState(values: Readonly<Record<string, string>>): FormState {
const copy: Record<string, string> = {};
for (const [name, value] of Object.entries(values)) {
if (name === "") throw new Error("a field name must not be empty");
if (typeof value !== "string") throw new Error(`the value of field "${name}" must be a string`);
copy[name] = value;
}
return { values: copy, initial: { ...copy }, touched: [], errors: {}, submitCount: 0, status: "editing" };
}formReducer throws on bad input 22 tests
export function formReducer(state: FormState, action: FormAction): FormState
| state | FormState | the current state; never changed |
| action | FormAction | what happened |
| returns | FormState | the next state, a new value |
For example
formReducer(values …, initial …, touched , errors …, submit count 0, status editing, type change, name email, value ada@example.com)→ values …, initial …, touched , errors …, submit count 0, status editing change sets one value and leaves the restformReducer(values …, initial …, touched email, errors …, submit count 1, status editing, type change, name email, value a)→ values …, initial …, touched email, errors …, submit count 1, status editing change does not touch the field or clear its error (validated does that)formReducer(values …, initial …, touched , errors …, submit count 0, status editing, type change, name email, value )→ values …, initial …, touched , errors …, submit count 0, status editing change to an empty value clears a field
import { formReducer } from "#fune/form.state@^1";
import type { FormAction, FormState } from "./form_state_types.ts";
/**
* The next state of a form after one action. Pure: the state passed in is
* never changed, so it works as React's useReducer reducer, in a test, on a
* server or in any other framework's store.
*/
export function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "change": {
const name = fieldName(state, action, "change");
if (typeof action.value !== "string") throw new Error("a change action needs a value");
// Editing after a successful submit means the form no longer matches
// what was sent; during a submit the request is already in flight.
const status = state.status === "submitted" ? "editing" : state.status;
return { ...copy(state), values: { ...state.values, [name]: action.value }, status };
}
case "blur": {
const name = fieldName(state, action, "blur");
return { ...copy(state), touched: inFormOrder(state, [...state.touched, name]) };
}
case "validated": {
if (action.errors === undefined || action.errors === null) throw new Error("a validated action needs errors");
return { ...copy(state), errors: { ...action.errors } };
}
case "submit": {
// A second click while the first submit is in flight changes nothing.
if (state.status === "submitting") return copy(state);
const valid = Object.keys(state.errors).length === 0;
return {
...copy(state),
touched: Object.keys(state.values),
submitCount: state.submitCount + 1,
status: valid ? "submitting" : "editing",
};
}
case "submitted": {
if (state.status !== "submitting") throw new Error("no submit is in progress");
return { ...copy(state), status: "submitted" };
}
case "failed": {
if (state.status !== "submitting") throw new Error("no submit is in progress");
return { ...copy(state), errors: { ...(action.errors ?? {}) }, status: "editing" };
}
case "reset":
return {
values: { ...state.initial },
initial: { ...state.initial },
touched: [],
errors: {},
submitCount: 0,
status: "editing",
};
default:
throw new Error(`unknown form action type "${String((action as { type: unknown }).type)}"`);
}
}
function copy(state: FormState): FormState {
return {
values: { ...state.values },
initial: { ...state.initial },
touched: [...state.touched],
errors: { ...state.errors },
submitCount: state.submitCount,
status: state.status,
};
}
function fieldName(state: FormState, action: FormAction, type: string): string {
const name = action.name;
if (typeof name !== "string" || name === "") throw new Error(`a ${type} action needs a name`);
if (!Object.prototype.hasOwnProperty.call(state.values, name)) throw new Error(`unknown field "${name}"`);
return name;
}
// Touched fields, once each, in the order of the form's values.
function inFormOrder(state: FormState, names: readonly string[]): string[] {
const set = new Set(names);
return Object.keys(state.values).filter((name) => set.has(name));
}visibleErrors 9 tests
export function visibleErrors(state: FormState): Readonly<Record<string, string>>
| state | FormState | |
| returns | map<string> | the messages to show now: none before the first submit, then every error, in the form's order |
For example
visibleErrors(values …, initial …, touched email, name, errors …, submit count 0, status editing)→ nothing shows before the first submit, even for a touched fieldvisibleErrors(values …, initial …, touched email, password, name, errors …, submit count 1, status editing)→ email Enter an email address, name Enter your name after a submit every error shows, in the form's order, not the validator'svisibleErrors(values …, initial …, touched email, password, name, errors …, submit count 1, status submitting)→ no errors after a submit shows nothing
import { visibleErrors } from "#fune/form.state@^1";
import type { FormState } from "./form_state_types.ts";
/**
* The error messages to show now. GOV.UK validates when the person submits,
* not as they type or leave a field, so nothing shows before the first submit
* attempt; after it, every error shows (a submit touches every field), and
* keeps showing until a validated action clears it. Fields come in the form's
* order, then any error for a name that is not a field (a form-wide message
* from the server) in the order given. An empty message is no error.
*/
export function visibleErrors(state: FormState): Readonly<Record<string, string>> {
const shown: Record<string, string> = {};
if (state.submitCount === 0) return shown;
const touched = new Set(state.touched);
for (const name of Object.keys(state.values)) {
const message = state.errors[name];
if (touched.has(name) && typeof message === "string" && message !== "") shown[name] = message;
}
for (const [name, message] of Object.entries(state.errors)) {
if (!(name in shown) && !Object.prototype.hasOwnProperty.call(state.values, name) && message !== "") shown[name] = message;
}
return shown;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add form.state
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add form.state --only initialFormState
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./form.state-1.0.1-typescript.fune, or fetch it from a terminal with fune pull form.state@1.0.1:typescript.
It is TypeScript only, so there is no package for Python or Rust. The full package is one file too: form.state-1.0.1.fune, 40,615 bytes, sha256 e87c728a244fcbb1336f68a8bc50ca6be08eb2194ab9d3a2a40882aa71372d5d.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before form.state.initialFormState
// fune: before form.state.formReducer
// fune: before form.state.visibleErrors
after — your function gets the result and the arguments, and returns the final result.
// fune: after form.state.initialFormState
// fune: after form.state.formReducer
// fune: after form.state.visibleErrors
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show form.state --steps.
// fune: step form.state.<fn> after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript alone, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
initialFormState 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a sign-up form starts empty, untouched and unsubmitted | email , password , name | → | values …, initial …, touched , errors …, submit count 0, status editing |
| starting values are kept, and are what reset returns to | email ada@example.com, name Ada | → | values …, initial …, touched , errors …, submit count 0, status editing |
| the fields keep the order given, not alphabetical | surname , given , dob | → | values …, initial …, touched , errors …, submit count 0, status editing |
| a form with no fields is allowed | → | values …, initial …, touched , errors …, submit count 0, status editing | |
| a checkbox group's value is its checked options joined by commas | contact email,sms, terms | → | values …, initial …, touched , errors …, submit count 0, status editing |
| values are kept exactly, spaces and all (normalising is the validator's job) | email Ada@Example.COM | → | values …, initial …, touched , errors …, submit count 0, status editing |
| a single field | q caps | → | values …, initial …, touched , errors …, submit count 0, status editing |
| a dotted field name is just a name | address.line1 1 High St, address.postcode SW1A 1AA | → | values …, initial …, touched , errors …, submit count 0, status editing |
| an empty field name is refused | x | → | error: a field name must not be empty |
formReducer 22 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| change sets one value and leaves the rest | values …, initial …, touched , errors …, submit count 0, status editing, type change, name email, value ada@example.com | → | values …, initial …, touched , errors …, submit count 0, status editing |
| change does not touch the field or clear its error (validated does that) | values …, initial …, touched email, errors …, submit count 1, status editing, type change, name email, value a | → | values …, initial …, touched email, errors …, submit count 1, status editing |
| change to an empty value clears a field | values …, initial …, touched , errors …, submit count 0, status editing, type change, name email, value | → | values …, initial …, touched , errors …, submit count 0, status editing |
| editing after a successful submit returns the form to editing | values …, initial …, touched email, password, name, errors …, submit count 1, status submitted, type change, name name, value Ada | → | values …, initial …, touched email, password, name, errors …, submit count 1, status editing |
| blur marks the field touched, kept in the form's order | values …, initial …, touched name, errors …, submit count 0, status editing, type blur, name email | → | values …, initial …, touched email, name, errors …, submit count 0, status editing |
| blurring a touched field again changes nothing | values …, initial …, touched email, errors …, submit count 0, status editing, type blur, name email | → | values …, initial …, touched email, errors …, submit count 0, status editing |
| validated replaces the errors with a validator's fields | values …, initial …, touched , errors …, submit count 0, status editing, type validated, errors … | → | values …, initial …, touched , errors …, submit count 0, status editing |
| validated with no fields clears every error | values …, initial …, touched email, password, name, errors …, submit count 1, status editing, type validated, errors … | → | values …, initial …, touched email, password, name, errors …, submit count 1, status editing |
| submit with errors counts the attempt and touches every field, but does not submit | values …, initial …, touched password, errors …, submit count 0, status editing, type submit | → | values …, initial …, touched email, password, name, errors …, submit count 1, status editing |
| submit with no errors starts submitting | values …, initial …, touched , errors …, submit count 2, status editing, type submit | → | values …, initial …, touched email, password, name, errors …, submit count 3, status submitting |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a second submit while one is in flight changes nothing (a double click) | values …, initial …, touched email, password, name, errors …, submit count 1, status submitting, type submit | → | values …, initial …, touched email, password, name, errors …, submit count 1, status submitting |
| submitted ends a submit that succeeded | values …, initial …, touched email, password, name, errors …, submit count 1, status submitting, type submitted | → | values …, initial …, touched email, password, name, errors …, submit count 1, status submitted |
| failed ends the submit with the server's errors, a form-wide one included | values …, initial …, touched email, password, name, errors …, submit count 1, status submitting, type failed, errors … | → | values …, initial …, touched email, password, name, errors …, submit count 1, status editing |
| failed without errors (the network dropped) just ends the submit | values …, initial …, touched email, password, name, errors …, submit count 1, status submitting, type failed | → | values …, initial …, touched email, password, name, errors …, submit count 1, status editing |
| reset returns to the initial values and forgets everything else | values …, initial …, touched email, password, name, errors …, submit count 4, status submitted, type reset | → | values …, initial …, touched , errors …, submit count 0, status editing |
| an unknown action type is refused | values …, initial …, touched , errors …, submit count 0, status editing, type submitt | → | error: unknown form action type "submitt" |
| a change without a name is refused | values …, initial …, touched , errors …, submit count 0, status editing, type change, value x | → | error: a change action needs a name |
| a blur without a name is refused | values …, initial …, touched , errors …, submit count 0, status editing, type blur | → | error: a blur action needs a name |
| a change to a field the form does not have is refused (a typo) | values …, initial …, touched , errors …, submit count 0, status editing, type change, name emial, value x | → | error: unknown field "emial" |
| a change without a value is refused | values …, initial …, touched , errors …, submit count 0, status editing, type change, name email | → | error: a change action needs a value |
| validated without errors is refused | values …, initial …, touched , errors …, submit count 0, status editing, type validated | → | error: a validated action needs errors |
| submitted with no submit in flight is refused | values …, initial …, touched , errors …, submit count 0, status editing, type submitted | → | error: no submit is in progress |
visibleErrors 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| nothing shows before the first submit, even for a touched field | values …, initial …, touched email, name, errors …, submit count 0, status editing | → | |
| after a submit every error shows, in the form's order, not the validator's | values …, initial …, touched email, password, name, errors …, submit count 1, status editing | → | email Enter an email address, name Enter your name |
| no errors after a submit shows nothing | values …, initial …, touched email, password, name, errors …, submit count 1, status submitting | → | |
| a fixed field's error goes once validated clears it | values …, initial …, touched email, password, name, errors …, submit count 2, status editing | → | name Enter your name |
| a server error for a name that is not a field comes after the fields | values …, initial …, touched email, password, name, errors …, submit count 1, status editing | → | password Use at least 15 characters., form Sorry, there is a problem with the service |
| an empty message is no error | values …, initial …, touched email, password, name, errors …, submit count 1, status editing | → | name Enter your name |
| after reset nothing shows | values …, initial …, touched , errors …, submit count 0, status editing | → | |
| after a submit only touched fields show, should a state leave one untouched | values …, initial …, touched email, errors …, submit count 1, status editing | → | email Enter an email address |
| a form with no fields shows its form-wide error after a submit | values …, initial …, touched , errors …, submit count 1, status editing | → | form Try again later |
More from the author
It holds no validation of its own. A `validate-*` capability (`auth.validate-registration`, `auth.validate-login`...) checks the values and its `fields` (field name to message) is dispatched as a `validated` action, so the browser and the API use the very same check.
## With React
"use client";
import { useReducer, type FormEvent } from "react";
import { formReducer, initialFormState, visibleErrors } from "#fune/form.state@^1";
import { validateRegistration } from "#fune/auth.validate-registration@^1";
import { passwordPolicy } from "#fune/auth.password-policy@^1";
import { TextField } from "#fune/react.form.text-field@^1";
import { PasswordField } from "#fune/react.form.password-field@^1";
import { ErrorSummary, errorSummaryItems } from "#fune/react.form.error-summary@^1";
import { Button } from "#fune/react.form.button@^1";
const policy = passwordPolicy("nist-800-63b-4-single-factor");
const ORDER = ["email", "password", "name"];
export function SignUp() {
const [state, dispatch] = useReducer(formReducer, initialFormState({ email: "", password: "", name: "" }));
const visible = visibleErrors(state);
const field = (name: string) => ({
id: name,
value: state.values[name],
error: visible[name],
onChange: (value: string) => dispatch({ type: "change", name, value }),
onBlur: () => dispatch({ type: "blur", name }),
});
async function onSubmit(event: FormEvent) {
event.preventDefault();
if (state.status === "submitting") return; // a double click: one request is enough
const { email, password, name } = state.values;
const check = validateRegistration(email, password, name, policy);
dispatch({ type: "validated", errors: check.fields });
dispatch({ type: "submit" }); // counts the attempt; submitting only if no errors
// dispatch is queued: state is still the old value here, so decide from check.valid
if (!check.valid) return;
const response = await fetch("/api/register", { method: "POST", body: JSON.stringify(state.values) });
if (response.ok) dispatch({ type: "submitted" });
else dispatch({ type: "failed", errors: (await response.json()).fields ?? {} });
}
return (
<form onSubmit={onSubmit} noValidate>
<ErrorSummary items={errorSummaryItems(visible, ORDER, {})} />
<TextField label="Email address" type="email" autoComplete="email" spellCheck={false} {...field("email")} />
<PasswordField label="Password" autoComplete="new-password" policy={policy} {...field("password")} />
<TextField label="Full name" autoComplete="name" {...field("name")} />
<Button loading={state.status === "submitting"}>Create account</Button>
</form>
);
}`dispatch` does not change `state` straight away: React applies the actions before the next render, in order, so `validated` then `submit` sees the new errors, but the code after them still sees the old state. That is why the fetch is decided by `check.valid`. To re-check as the person types, dispatch `validated` after each `change` too: nothing shows until they have tried to submit, and after that each message goes as soon as the field is fixed, which is what GOV.UK recommends.
## Why a reducer and not a hook
A hook can only run inside a React component, and nothing that runs there can be checked by a vector. A reducer is a pure function: every transition above is pinned by a test case in `vectors.json`, it runs the same in a unit test, in a server action rebuilding a form after a failed post, in a Web Worker, or in another framework's store (Redux, Zustand, Vue, Svelte, plain `let state = formReducer(state, action)`). `useReducer` is all the glue React needs.
## The state
| Field | Meaning | |---|---| | `values` | each field's current value, in the form's order | | `initial` | the values `reset` goes back to | | `touched` | the fields the person has left (`blur`), or every field once they have tried to submit; in the form's order | | `errors` | the latest validation, field name to message | | `submitCount` | how many times they have tried to submit | | `status` | `editing`, `submitting` (a valid submit is in flight) or `submitted` (it succeeded) |
The order of `values` is the order of the form, and `touched` and `visibleErrors` keep it, so an error summary lists problems top to bottom whatever order the validator found them in. (JavaScript orders object keys that look like whole numbers, `"0"`, `"12"`, before all others, so do not name fields that way.)
## The actions
| Action | Effect | |---|---| | `{ type: "change", name, value }` | sets one value. Leaves the errors as they were until the next `validated`. Returns a `submitted` form to `editing`, since it no longer matches what was sent. | | `{ type: "blur", name }` | marks the field touched | | `{ type: "validated", errors }` | replaces the errors with the validator's `fields`; `{}` clears them | | `{ type: "submit" }` | counts the attempt and touches every field; `submitting` only when there are no errors. Ignored while a submit is already in flight (a double click). | | `{ type: "submitted" }` | the submit succeeded | | `{ type: "failed", errors? }` | the submit failed; the server's errors (such as "An account with this email already exists") replace the errors, or none if it just failed (then say so in your own message) | | `{ type: "reset" }` | back to the initial values, untouched, no errors, never submitted |
Loud errors, so a typo is caught the first time it runs: an unknown `type` (`unknown form action type "submitt"`), a `change` or `blur` without a `name` (`a change action needs a name`), a name the form does not have (`unknown field "emial"`), a `change` without a `value`, a `validated` without `errors`, and `submitted` or `failed` when no submit is in progress. The input state is never changed; every action answers a new one.
## Which errors are visible
GOV.UK validates when the person submits, not as they type or when they leave a field, because messages that appear mid-answer interrupt and confuse. So `visibleErrors` shows nothing before the first submit attempt. After it, it shows the error of every touched field (a submit touches them all), in the form's order, then any message for a name that is not a field (a form-wide error from the server) in the order given. An empty message counts as no error. `touched` is kept anyway, for a form that chooses to show an error when the person leaves a field: filter `state.errors` by `state.touched` yourself.
## Other kinds of field
Every value is a string, as the browser submits it. A single checkbox is its value when ticked (`"yes"`) and `""` when not. A checkbox group (`react.form.checkbox-group`) is its ticked values joined with commas: `values={state.values.contact ? state.values.contact.split(",") : []}` and `onChange={(ticked) => dispatch({ type: "change", name: "contact", value: ticked.join(",") })}`. The option values are your own codes, so keep commas out of them. Radios and selects are their one value; a date is three fields (`dob-day`, `dob-month`, `dob-year`) or one ISO string, whichever your validator reads.
A note on the vectors: the harness compares maps without regard to key order, so the form's order in `visibleErrors` is stated here and implemented, but pinned by the vectors only for `touched`, which is a list.
Sources: GOV.UK Design System, "Validation" pattern https://design-system.service.gov.uk/patterns/validation/ and "Error summary" https://design-system.service.gov.uk/components/error-summary/; React, `useReducer` https://react.dev/reference/react/useReducer.
## Notices
Portions derived from GOV.UK Frontend (https://github.com/alphagov/govuk-frontend), Copyright (c) 2017 Crown Copyright (Government Digital Service), under the MIT License; the full notice is in NOTICE.
1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 1,208 |
| README.md | 8,317 |
| impl/typescript/form_reducer.ts | 3,199 |
| impl/typescript/initial_form_state.ts | 768 |
| impl/typescript/visible_errors.ts | 1,145 |
| vectors.json | 17,460 |