react.form.error-summary
A "There is a problem" box linking to each field in error, focused on arrival, built from a validator's fields (GOV.UK).
React ^19 · TypeScript only A React component: use it from a TypeScript project with React installed. A client component ("use client").
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 21 tests, run in TypeScript.errorSummaryItems 13 · ErrorSummary 8
What it does
The GOV.UK Design System error summary: a box at the top of the page headed "There is a problem", listing every error on the page, each a link to the field it is about. It takes focus when it appears, so a keyboard or screen reader user who has just pressed "Continue" lands on it and hears what to fix. `errorSummaryItems` builds its list from the `fields` map the `validate-*` capabilities return (field name to message).
"use client";
import { ErrorSummary, errorSummaryItems } from "#fune/react.form.error-summary@^1";
import { validateRegistration } from "#fune/auth.validate-registration@^1";
import { validateDateParts } from "#fune/form.date-parts@^1";
const check = validateRegistration(email, password, name, policy);
const dob = validateDateParts(dobParts, "Date of birth", { today, timing: "past", notBefore: null, notAfter: null });
const fields = { ...check.fields, ...(dob.message ? { dob: dob.message } : {}) };
<ErrorSummary items={errorSummaryItems(fields, ["name", "email", "password", "dob"], { dob: `dob-${dob.fields[0]}` })} />
The functions
A group: 2 functions that work together, each in its own file, each pinned by its own tests, written for React ^19. A project can install only the ones it calls.
- errorSummaryItems (fields: map<string>, order: string[], targets: map<string>) -> ErrorSummaryItem[]
- ErrorSummary (props: ErrorSummaryProps) -> element
The types it declares, generated into your project
/** One error in the summary, and the field it links to. */
export interface ErrorSummaryItem {
/** the field name it came from */
readonly field: string;
/** the message, worded as it is beside the field */
readonly text: string;
/** "#" + the id of the field's input */
readonly href: string;
}
/** Every error on a page, listed at the top of it. */
export interface ErrorSummaryProps {
/** the errors, usually from errorSummaryItems; nothing renders when empty */
readonly items: readonly ErrorSummaryItem[];
/** the summary's id; React's useId() when left out */
readonly id?: string;
/** "There is a problem" when left out */
readonly title?: ReactNode;
/** a line under the title, if the errors need an introduction */
readonly description?: ReactNode;
/** do not move focus to the summary when it appears */
readonly disableAutoFocus?: boolean;
/** added to the summary's classes */
readonly className?: string;
}
Once installed, your code imports each one from the group's module.
errorSummaryItems throws on bad input 13 tests
export function errorSummaryItems(fields: Readonly<Record<string, string>>, order: readonly string[], targets: Readonly<Record<string, string>>): readonly ErrorSummaryItem[]
| fields | map<string> | field name to message, as the validate-* capabilities return it |
| order | string[] | the fields in the order the form shows them; any others follow in the map's order |
| targets | map<string> | field name to the id to link to, where it is not the name itself (a date's "dob" to "dob-day") |
| returns | ErrorSummaryItem[] | one item per message, in form order, linking to "#" + the target id |
For example
errorSummaryItems(email Enter an email address, email, )→ ×1 one field, linked by its own nameerrorSummaryItems(password Enter a password, email Enter an email address, name, email, password, )→ ×2 form order wins over the map's ordererrorSummaryItems(terms Accept the terms, email Enter an email address, extra Something else is wrong, email, )→ ×3 fields the order leaves out follow, in the map's order
import { errorSummaryItems } from "#fune/react.form.error-summary@^1";
import type { ErrorSummaryItem } from "./react_form_error_summary_types.ts";
/**
* The error summary's list from a validator's field messages: the fields in
* the order the form shows them (so the summary reads top to bottom, as
* GOV.UK asks), then any the order left out, in the map's order, so no error
* is ever dropped. Each links to its input's id: the field name, unless
* targets says otherwise, as for a date whose link goes to its first box in
* error ("dob" -> "dob-day"). An empty message is no error.
*/
export function errorSummaryItems(
fields: Readonly<Record<string, string>>,
order: readonly string[],
targets: Readonly<Record<string, string>>,
): readonly ErrorSummaryItem[] {
const names: string[] = [];
for (const name of [...order, ...Object.keys(fields)]) {
if (Object.hasOwn(fields, name) && fields[name] !== "" && !names.includes(name)) names.push(name);
}
return names.map((field) => {
const target = Object.hasOwn(targets, field) ? targets[field] : field;
if (target === "") throw new Error(`the error summary link for "${field}" needs an id`);
if (/\s/.test(target)) throw new Error(`a field id cannot contain spaces ("${target}"): the error summary links to it by id`);
return { field, text: fields[field], href: `#${target}` };
});
}ErrorSummary 8 tests
export function ErrorSummary(props: ErrorSummaryProps): JSX.Element
Its props, ErrorSummaryProps. A ? marks one the caller may leave out.
| items | readonly ErrorSummaryItem[] | the errors, usually from errorSummaryItems; nothing renders when empty |
| id? | string | the summary's id; React's useId() when left out |
| title? | ReactNode | "There is a problem" when left out |
| description? | ReactNode | a line under the title, if the errors need an introduction |
| disableAutoFocus? | boolean | do not move focus to the summary when it appears |
| className? | string | added to the summary's classes |
| renders | JSX.Element |
For example
-
GOV.UK's example: two errors under There is a problem
<ErrorSummary id="errors" items={[{"field":"name","text":"Enter your full name","href":"#full-name"},{"field":"issued","text":"The date your passport was issued must be in the past","href":"#passport-issued-day"}]} />renders
<div class="fune-error-summary" id="errors" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">There is a problem</h2><div class="fune-error-summary-body"><ul class="fune-error-summary-list"><li><a href="#full-name">Enter your full name</a></li><li><a href="#passport-issued-day">The date your passport was issued must be in the past</a></li></ul></div></div></div> -
one error still gets a summary
<ErrorSummary id="errors" items={[{"field":"email","text":"Enter an email address","href":"#email"}]} />renders
<div class="fune-error-summary" id="errors" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">There is a problem</h2><div class="fune-error-summary-body"><ul class="fune-error-summary-list"><li><a href="#email">Enter an email address</a></li></ul></div></div></div>
import { ErrorSummary } from "#fune/react.form.error-summary@^1";
"use client";
import { useEffect, useId, useRef, type JSX, type MouseEvent, type ReactNode } from "react";
import type { ErrorSummaryProps } from "./react_form_error_summary_types.ts";
function present(node: ReactNode): boolean {
return node !== null && node !== undefined && node !== false && node !== "";
}
// GOV.UK moves focus to the field and scrolls its question into view: the
// legend when the input is in a fieldset (a date's day box), else its label.
function followLink(event: MouseEvent<HTMLAnchorElement>, href: string): void {
const input = document.getElementById(href.slice(1));
if (!input) return;
event.preventDefault();
const legend = input.closest("fieldset")?.querySelector("legend");
const label = document.querySelector(`label[for="${CSS.escape(input.id)}"]`);
(legend ?? label ?? input).scrollIntoView();
input.focus({ preventScroll: true });
}
/**
* The GOV.UK error summary: "There is a problem" and a link to each field in
* error, at the top of the page. It takes focus when it appears, and again
* whenever the errors change, so a keyboard or screen reader user lands on
* it after submitting; role="alert" announces it. Nothing renders when there
* are no items.
*/
export function ErrorSummary(props: ErrorSummaryProps): JSX.Element {
const auto = useId();
const box = useRef<HTMLDivElement>(null);
const { items, title, description, disableAutoFocus, className } = props;
const id = props.id ?? auto;
const errors = items.map((item) => `${item.href} ${item.text}`).join("\n");
useEffect(() => {
if (errors !== "" && !disableAutoFocus) box.current?.focus();
}, [errors, disableAutoFocus]);
if (items.length === 0) return <></>;
return (
<div className={className ? `fune-error-summary ${className}` : "fune-error-summary"} id={id} tabIndex={-1} ref={box}>
<div role="alert">
<h2 className="fune-error-summary-title">{present(title) ? title : "There is a problem"}</h2>
<div className="fune-error-summary-body">
{present(description) ? <p>{description}</p> : null}
<ul className="fune-error-summary-list">
{items.map((item) => (
<li key={item.href + " " + item.field}>
<a href={item.href} onClick={(event) => followLink(event, item.href)}>
{item.text}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
);
}Install
npm install react react-dom
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project) with React ^19 installed (Functional Weave does not ship it, and fune build stops with the npm install line if it is missing), 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 react.form.error-summary
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add react.form.error-summary --only errorSummaryItems
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./react.form.error-summary-1.0.0-typescript.fune, or fetch it from a terminal with fune pull react.form.error-summary@1.0.0:typescript.
It is React ^19 · TypeScript only, so there is no package for Python or Rust. The full package is one file too: react.form.error-summary-1.0.0.fune, 20,383 bytes, sha256 1581a4554dcaae6518f235938169dc3bab0197845e2113e2f84749b5d7b8b7f4.
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 react.form.error-summary.errorSummaryItems
// fune: before react.form.error-summary.ErrorSummary
after — your function gets the result and the arguments, and returns the final result.
// fune: after react.form.error-summary.errorSummaryItems
// fune: after react.form.error-summary.ErrorSummary
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 react.form.error-summary --steps.
// fune: step react.form.error-summary.<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, with the React in tooling/react, 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. A component’s tests are render tests: the props go in, and the HTML renderToStaticMarkup makes of them has to match exactly; a React warning during the render fails the test.
errorSummaryItems 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| one field, linked by its own name | email Enter an email address, email, | → | ×1 |
| form order wins over the map's order | password Enter a password, email Enter an email address, name, email, password, | → | ×2 |
| fields the order leaves out follow, in the map's order | terms Accept the terms, email Enter an email address, extra Something else is wrong, email, | → | ×3 |
| a date links to its first box in error | dob Date of birth must include a month, name Enter your full name, name, dob, dob dob-month | → | ×2 |
| a target for a field without an error is ignored | name Enter your full name, name, dob, dob dob-day | → | ×1 |
| no errors, no items | , name, email, | → | |
| an empty message is no error | name , email Enter an email address, name, email, | → | ×1 |
| a name repeated in the order is listed once | email Enter an email address, email, email, | → | ×1 |
| an empty order keeps the map's order | b B is wrong, a A is wrong, , | → | ×2 |
| a GOV.UK-style id with dashes, from GOV.UK's example | full name Enter your full name, , full name full-name-input | → | ×1 |
Show the other 3 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a target with a space is refused | name Enter your full name, , name full name | → | error: a field id cannot contain spaces ("full name") |
| a field name with a space and no target is refused | first name Enter your first name, , | → | error: a field id cannot contain spaces |
| an empty target is refused | name Enter your full name, , name | → | error: the error summary link for "name" needs an id |
ErrorSummary 8 tests
-
GOV.UK's example: two errors under There is a problem
<ErrorSummary id="errors" items={[{"field":"name","text":"Enter your full name","href":"#full-name"},{"field":"issued","text":"The date your passport was issued must be in the past","href":"#passport-issued-day"}]} />renders
<div class="fune-error-summary" id="errors" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">There is a problem</h2><div class="fune-error-summary-body"><ul class="fune-error-summary-list"><li><a href="#full-name">Enter your full name</a></li><li><a href="#passport-issued-day">The date your passport was issued must be in the past</a></li></ul></div></div></div> -
one error still gets a summary
<ErrorSummary id="errors" items={[{"field":"email","text":"Enter an email address","href":"#email"}]} />renders
<div class="fune-error-summary" id="errors" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">There is a problem</h2><div class="fune-error-summary-body"><ul class="fune-error-summary-list"><li><a href="#email">Enter an email address</a></li></ul></div></div></div> -
no items render nothing
<ErrorSummary id="errors" items={[]} />renders
-
a title of its own
<ErrorSummary id="errors" title="Check your answers" items={[{"field":"email","text":"Enter an email address","href":"#email"}]} />renders
<div class="fune-error-summary" id="errors" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">Check your answers</h2><div class="fune-error-summary-body"><ul class="fune-error-summary-list"><li><a href="#email">Enter an email address</a></li></ul></div></div></div> -
an empty title falls back to There is a problem
<ErrorSummary id="errors" title="" items={[{"field":"email","text":"Enter an email address","href":"#email"}]} />renders
<div class="fune-error-summary" id="errors" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">There is a problem</h2><div class="fune-error-summary-body"><ul class="fune-error-summary-list"><li><a href="#email">Enter an email address</a></li></ul></div></div></div> -
a description goes above the list
<ErrorSummary id="errors" description="Fix the following to continue" items={[{"field":"email","text":"Enter an email address","href":"#email"}]} />renders
<div class="fune-error-summary" id="errors" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">There is a problem</h2><div class="fune-error-summary-body"><p>Fix the following to continue</p><ul class="fune-error-summary-list"><li><a href="#email">Enter an email address</a></li></ul></div></div></div> -
a class name joins the summary's
<ErrorSummary id="top-errors" className="page-errors" disableAutoFocus items={[{"field":"email","text":"Enter an email address","href":"#email"}]} />renders
<div class="fune-error-summary page-errors" id="top-errors" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">There is a problem</h2><div class="fune-error-summary-body"><ul class="fune-error-summary-list"><li><a href="#email">Enter an email address</a></li></ul></div></div></div> -
messages are escaped
<ErrorSummary id="e" items={[{"field":"q","text":"Search must not include <script> & \"quotes\"","href":"#q"}]} />renders
<div class="fune-error-summary" id="e" tabindex="-1"><div role="alert"><h2 class="fune-error-summary-title">There is a problem</h2><div class="fune-error-summary-body"><ul class="fune-error-summary-list"><li><a href="#q">Search must not include <script> & "quotes"</a></li></ul></div></div></div>
More from the author
## errorSummaryItems(fields, order, targets)
- **Order**: the fields named in `order` first, in that order, so the summary reads in the same order as the form; then any other field in `fields`, in the map's order, so an error is never lost because the order forgot it. A name listed twice appears once; a name with no error is skipped. - **Links** are `"#" + id`. The id is the field name, unless `targets` gives another: GOV.UK links an error about a date to its first box in error, so with `react.form.date-input` and `form.date-parts` pass `{ dob: "dob-" + check.fields[0] }` ("dob-month" for a missing month, "dob-day" when the whole date is in error). - An empty message is not an error. An id that is empty or contains a space throws, since it could not be linked to. - The text is the message as given: GOV.UK asks for the same wording in the summary as beside the field, which passing the same map to both ensures.
## ErrorSummary
- `items` from `errorSummaryItems` (or written by hand). With none it renders nothing, so it can stay on the page permanently. - `title` defaults to GOV.UK's required "There is a problem"; `description` adds a line above the list. - The box has `tabindex="-1"` and takes focus when it appears and again whenever the list of errors changes (a second failed submit); `disableAutoFocus` turns that off. The inner `role="alert"` announces it. - A link, clicked, moves focus to the field and scrolls its question into view (the fieldset's legend for a box inside one, else the field's label), as GOV.UK's script does. A link whose target is not on the page is left to the browser. - Focus and scrolling are effects, so it is a client component (`"use client"`).
**What the vectors cover**: the markup of every state, and `errorSummaryItems` in full. Moving focus on arrival and on a click needs a browser; the harness renders on the server with no DOM, so focus is not tested by it.
Also follow GOV.UK's validation pattern: put "Error: " at the start of the page's `<title>` while there are errors.
Classes: `fune-error-summary`, `fune-error-summary-title`, `fune-error-summary-body`, `fune-error-summary-list`.
Sources: GOV.UK Design System, "Error summary" https://design-system.service.gov.uk/components/error-summary/ (markup, the "There is a problem" heading, linking to a date input's first field in error) and the "Validation" pattern https://design-system.service.gov.uk/patterns/validation/.
Files
| Path | Bytes |
|---|---|
| README.md | 3,569 |
| impl/typescript/error_summary.tsx | 2,456 |
| impl/typescript/error_summary_items.ts | 1,308 |
| vectors.json | 7,434 |