react.form.select
A labelled drop-down list with a hint, error message and an optional empty first option (GOV.UK select).
React ^19 · TypeScript only A React component: use it from a TypeScript project with React installed. A client component ("use client").
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 15 tests, run in TypeScript.
What it does
A drop-down list with its label, an optional hint and error message, and the accessible wiring done: the label's `for`, the select's `aria-describedby` (hint, then error) and `aria-invalid` when there is an error. It is the GOV.UK Design System select, built on `react.form.form-field`.
**Try not to use it.** GOV.UK: "Before using the select component, try asking users questions which will allow you to present them with fewer options", and then use radios (`react.form.radio-group`). Selects are hard for many people to use, hide the options until opened, and are awkward on a phone. Keep this for long lists a person knows the answer to, such as a country, or for sorting a list of results.
For example
-
a label and a select, submitted under its id
<Select id="sort" label="Sort by" options={[{"value":"published","label":"Recently published"},{"value":"updated","label":"Recently updated"}]} />renders
<div class="fune-field"><label class="fune-label" for="sort">Sort by</label><select class="fune-select" id="sort" name="sort"><option value="published">Recently published</option><option value="updated">Recently updated</option></select></div> -
a controlled value selects its option
<Select id="sort" label="Sort by" value="updated" options={[{"value":"published","label":"Recently published"},{"value":"updated","label":"Recently updated"},{"value":"views","label":"Most views"}]} />renders
<div class="fune-field"><label class="fune-label" for="sort">Sort by</label><select class="fune-select" id="sort" name="sort"><option value="published">Recently published</option><option value="updated" selected="">Recently updated</option><option value="views">Most views</option></select></div>
The function
Written for React ^19, in TypeScript only. A component: it takes its props and renders HTML, which the tests below pin exactly.
A React capability: React ^19 · TypeScript only. This capability has no Python implementation, so it is shown in TypeScript. A Python project cannot use it: fune build stops and names where it was required. Your choice of Python is kept for every other page.
export function Select(props: SelectProps): JSX.Element
Its props, SelectProps. A ? marks one the caller may leave out.
| label | ReactNode | the question, or the field's name |
| options | readonly SelectOption[] | the options, in order |
| id? | string | the select's id; React's useId() when left out |
| name? | string | what the choice is submitted as; the id when left out |
| placeholder? | string | a first option with the value "", such as "Choose a country" |
| value? | string | the chosen value, for a controlled select (with onChange) |
| defaultValue? | string | the value chosen at first, for an uncontrolled one |
| onChange? | (value: string) => void | called with the value chosen |
| onBlur? | () => void | called when the select loses focus, to mark the field touched |
| autoComplete? | string | the autocomplete token, such as country or sex |
| hint? | ReactNode | help under the label |
| error? | ReactNode | what is wrong; marks the select invalid and describes it |
| required? | boolean | the browser refuses the form while the value is "" |
| disabled? | boolean | shown but not changeable, and not submitted |
| className? | string | added to the wrapper's classes |
| renders | JSX.Element |
The types it declares, generated into your project
/** One option in the list. */
export interface SelectOption {
/** what is submitted when it is chosen */
readonly value: string;
/** what the list shows; text only, since an option cannot hold markup */
readonly label: string;
/** listed but not choosable */
readonly disabled?: boolean;
}
/** One answer chosen from a long list. */
export interface SelectProps {
/** the question, or the field's name */
readonly label: ReactNode;
/** the options, in order */
readonly options: readonly SelectOption[];
/** the select's id; React's useId() when left out */
readonly id?: string;
/** what the choice is submitted as; the id when left out */
readonly name?: string;
/** a first option with the value "", such as "Choose a country" */
readonly placeholder?: string;
/** the chosen value, for a controlled select (with onChange) */
readonly value?: string;
/** the value chosen at first, for an uncontrolled one */
readonly defaultValue?: string;
/** called with the value chosen */
readonly onChange?: (value: string) => void;
/** called when the select loses focus, to mark the field touched */
readonly onBlur?: () => void;
/** the autocomplete token, such as country or sex */
readonly autoComplete?: string;
/** help under the label */
readonly hint?: ReactNode;
/** what is wrong; marks the select invalid and describes it */
readonly error?: ReactNode;
/** the browser refuses the form while the value is "" */
readonly required?: boolean;
/** shown but not changeable, and not submitted */
readonly disabled?: boolean;
/** added to the wrapper's classes */
readonly className?: string;
}
Your code names it in one line, in the file that uses it
import { Select } from "#fune/react.form.select@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
"use client";
import { useId, type JSX, type ReactNode } from "react";
import { FormField, fieldIds } from "./react_form_form_field.ts"; ← from react.form.form-field ^1.0.0 · built alongside by fune
import type { SelectProps } from "./react_form_select_types.ts";
function present(node: ReactNode): boolean {
return node !== null && node !== undefined && node !== false && node !== "";
}
/**
* The GOV.UK select: a label, an optional hint and error message, and the
* select, described by both. A placeholder is a real first option with the
* value "", so a required select refuses the form until something else is
* chosen, and a post without a choice says so with an empty value.
*/
export function Select(props: SelectProps): JSX.Element {
const auto = useId();
const { label, options, placeholder, value, defaultValue, onChange, onBlur, autoComplete, hint, error, required, disabled, className } = props;
const id = props.id ?? auto;
const ids = fieldIds(id, present(hint), present(error));
const seen = new Set<string>(placeholder !== undefined ? [""] : []);
for (const o of options) {
if (seen.has(o.value)) throw new Error(`select option values must be unique: "${o.value}" is there twice (the placeholder's is "")`);
seen.add(o.value);
}
return (
<FormField id={id} label={label} hint={hint} error={error} className={className}>
<select
className={ids.error ? "fune-select fune-select--error" : "fune-select"}
id={ids.control}
name={props.name ?? id}
value={value}
defaultValue={defaultValue}
autoComplete={autoComplete}
required={required}
disabled={disabled}
aria-describedby={ids.describedBy ?? undefined}
aria-invalid={ids.error ? true : undefined}
onBlur={onBlur ? () => onBlur() : undefined}
onChange={(event) => onChange?.(event.target.value)}
>
{placeholder !== undefined ? <option value="">{placeholder}</option> : null}
{options.map((o) => (
<option key={o.value} value={o.value} disabled={o.disabled}>
{o.label}
</option>
))}
</select>
</FormField>
);
}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 its 1 dependency, 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.select
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./react.form.select-1.0.1-typescript.fune, or fetch it from a terminal with fune pull react.form.select@1.0.1: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.select-1.0.1.fune, 19,102 bytes, sha256 eb37bce68b970a74d07be29ccb8711517f95098898b26d664fe9b95499a7a0e8.
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.select
after — your function gets the result and the arguments, and returns the final result.
// fune: after react.form.select
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace react.form.form-field in react.form.select
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show react.form.select --steps.
// fune: step react.form.select 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.
-
a label and a select, submitted under its id
<Select id="sort" label="Sort by" options={[{"value":"published","label":"Recently published"},{"value":"updated","label":"Recently updated"}]} />renders
<div class="fune-field"><label class="fune-label" for="sort">Sort by</label><select class="fune-select" id="sort" name="sort"><option value="published">Recently published</option><option value="updated">Recently updated</option></select></div> -
a controlled value selects its option
<Select id="sort" label="Sort by" value="updated" options={[{"value":"published","label":"Recently published"},{"value":"updated","label":"Recently updated"},{"value":"views","label":"Most views"}]} />renders
<div class="fune-field"><label class="fune-label" for="sort">Sort by</label><select class="fune-select" id="sort" name="sort"><option value="published">Recently published</option><option value="updated" selected="">Recently updated</option><option value="views">Most views</option></select></div> -
a default value selects its option too, under a name of its own
<Select id="s1" name="sortBy" label="Sort by" defaultValue="views" options={[{"value":"updated","label":"Recently updated"},{"value":"views","label":"Most views"}]} />renders
<div class="fune-field"><label class="fune-label" for="s1">Sort by</label><select class="fune-select" id="s1" name="sortBy"><option value="updated">Recently updated</option><option value="views" selected="">Most views</option></select></div> -
a placeholder is the first option, with the value ""
<Select id="country" label="Country" placeholder="Choose a country" autoComplete="country" options={[{"value":"GB","label":"United Kingdom"},{"value":"IE","label":"Ireland"}]} />renders
<div class="fune-field"><label class="fune-label" for="country">Country</label><select class="fune-select" id="country" name="country" autoComplete="country"><option value="">Choose a country</option><option value="GB">United Kingdom</option><option value="IE">Ireland</option></select></div> -
a value of "" selects the placeholder
<Select id="country" label="Country" placeholder="Choose a country" value="" required options={[{"value":"GB","label":"United Kingdom"}]} />renders
<div class="fune-field"><label class="fune-label" for="country">Country</label><select class="fune-select" id="country" name="country" required=""><option value="" selected="">Choose a country</option><option value="GB">United Kingdom</option></select></div> -
hint and error: the select is described by both, hint first, and invalid
<Select id="location" label="Choose location" hint="This can be different to where you went before" error="Select a location" placeholder="" options={[{"value":"eastmidlands","label":"East Midlands"},{"value":"london","label":"London"}]} />renders
<div class="fune-field fune-field--error"><label class="fune-label" for="location">Choose location</label><div class="fune-hint" id="location-hint">This can be different to where you went before</div><p class="fune-error-message" id="location-error"><span class="fune-visually-hidden">Error:</span> Select a location</p><select class="fune-select fune-select--error" id="location" name="location" aria-describedby="location-hint location-error" aria-invalid="true"><option value=""></option><option value="eastmidlands">East Midlands</option><option value="london">London</option></select></div> -
a hint alone describes the select
<Select id="t" label="Title" hint="Optional" options={[{"value":"mr","label":"Mr"},{"value":"ms","label":"Ms"}]} />renders
<div class="fune-field"><label class="fune-label" for="t">Title</label><div class="fune-hint" id="t-hint">Optional</div><select class="fune-select" id="t" name="t" aria-describedby="t-hint"><option value="mr">Mr</option><option value="ms">Ms</option></select></div> -
a disabled option and a disabled select
<Select id="plan" label="Plan" disabled options={[{"value":"free","label":"Free"},{"value":"pro","label":"Pro (sold out)","disabled":true}]} />renders
<div class="fune-field"><label class="fune-label" for="plan">Plan</label><select class="fune-select" id="plan" name="plan" disabled=""><option value="free">Free</option><option value="pro" disabled="">Pro (sold out)</option></select></div> -
false booleans, empty hint and null error render nothing; a class joins the wrapper's
<Select id="p" label="Plan" required={false} disabled={false} hint="" error={null} className="narrow" options={[{"value":"free","label":"Free","disabled":false}]} />renders
<div class="fune-field narrow"><label class="fune-label" for="p">Plan</label><select class="fune-select" id="p" name="p"><option value="free">Free</option></select></div> -
a value that is no option selects nothing
<Select id="p" label="Plan" value="gold" options={[{"value":"free","label":"Free"},{"value":"pro","label":"Pro"}]} />renders
<div class="fune-field"><label class="fune-label" for="p">Plan</label><select class="fune-select" id="p" name="p"><option value="free">Free</option><option value="pro">Pro</option></select></div>
Show the other 5 tests
-
labels and values are escaped
<Select id="e" label="Q & A" options={[{"value":"a\"b","label":"<A> & B"}]} />renders
<div class="fune-field"><label class="fune-label" for="e">Q & A</label><select class="fune-select" id="e" name="e"><option value="a"b"><A> & B</option></select></div> -
no options and no placeholder is still a select, empty
<Select id="none" label="Nothing yet" options={[]} />renders
<div class="fune-field"><label class="fune-label" for="none">Nothing yet</label><select class="fune-select" id="none" name="none"></select></div> -
two options with one value are refused
<Select id="x" label="Pick" options={[{"value":"a","label":"A"},{"value":"a","label":"Also A"}]} />error: select option values must be unique: "a" is there twice
-
an option with the value "" beside a placeholder is refused
<Select id="x" label="Pick" placeholder="Choose" options={[{"value":"","label":"Nothing"}]} />error: select option values must be unique: "" is there twice
-
an id with a space is refused
<Select id="sort by" label="Sort by" options={[{"value":"a","label":"A"}]} />error: a field id cannot contain spaces
More from the author
"use client";
import { Select } from "#fune/react.form.select@^1";
<Select id="sort" label="Sort by" value={sort} onChange={setSort}
options={[
{ value: "published", label: "Recently published" },
{ value: "updated", label: "Recently updated" },
{ value: "views", label: "Most views" },
]} />- **placeholder** adds a first option with the value `""` ("Choose a country"). With **required**, the browser refuses the form until something else is chosen, and a post without a choice arrives as an empty value. An option of its own with the value `""` beside a placeholder is refused. - **onChange** is called with the chosen value, not the event. **value** with onChange is controlled; **defaultValue** alone is uncontrolled. React renders the chosen option as `selected`; a value that is no option selects nothing, so the browser shows the first. - **onBlur** is called, with nothing, when the select loses focus, so a form can mark the field touched (`form.state`'s blur action). - **Option labels are text** (`string`, not a node): an `<option>` cannot hold markup, and React warns if it is given an element. - **autoComplete** takes the HTML token, such as `country`, so a browser can fill it. - **id** is the select's id and, unless **name** says otherwise, the name it posts under; React's `useId()` when left out. - Option values must be unique (they are the React keys).
It is a client component (it listens for changes), so the built file starts with `"use client"`.
Classes: those of `react.form.form-field`, plus `fune-select` (and `fune-select--error`).
Sources: GOV.UK Design System, "Select" (markup, error state, and the advice to ask questions that allow radios instead) https://design-system.service.gov.uk/components/select/ and "Radios" https://design-system.service.gov.uk/components/radios/.
## 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,215 |
| README.md | 2,880 |
| impl/typescript.tsx | 2,125 |
| vectors.json | 7,042 |