Functional Weave
Code in Rust

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.

TypeScript only. This capability has no Rust implementation, so it is shown in TypeScript. A Rust project cannot use it: fune build stops and names where it was required. Your choice of Rust is kept for every other page.

  1. initialFormState (values: map<string>) -> FormState
  2. formReducer (state: FormState, action: FormAction) -> FormState
  3. 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
valuesmap<string>every field's starting value, in the order the form shows them
returnsFormStatenothing 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 unsubmitted
  • initialFormState(email ada@example.com, name Ada) → values …, initial …, touched , errors …, submit count 0, status editing starting values are kept, and are what reset returns to
  • initialFormState(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";
impl/typescript/initial_form_state.ts · 16 lines · open · raw
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
stateFormStatethe current state; never changed
actionFormActionwhat happened
returnsFormStatethe 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 rest
  • formReducer(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";
impl/typescript/form_reducer.ts · 81 lines · open · raw
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>>
stateFormState
returnsmap<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 field
  • visibleErrors(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's
  • visibleErrors(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";
impl/typescript/visible_errors.ts · 23 lines · open · raw
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
Download for TypeScript form.state-1.0.1-typescript.fune · 40,643 bytes sha256 5185acd34fa3f84694b0c1b9a1fef5cbe04207977f74c88367c48655b8a903db

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

CaseArgumentsExpected
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

CaseArgumentsExpected
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
CaseArgumentsExpected
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

CaseArgumentsExpected
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

PathBytes
NOTICE1,208
README.md8,317
impl/typescript/form_reducer.ts3,199
impl/typescript/initial_form_state.ts768
impl/typescript/visible_errors.ts1,145
vectors.json17,460