Functional Weave
Code in Python

form.state@1.0.1

README.md

8,317 bytes · view raw

# 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 (
    <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.