# form.state 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. 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 ```tsx "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 (
); } ``` `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.