Functional Weave
Code in TypeScript

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.0 (not the latest) · 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

  1. 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>
  2. 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.

export function Select(props: SelectProps): JSX.Element

Its props, SelectProps. A ? marks one the caller may leave out.

labelReactNodethe question, or the field's name
optionsreadonly SelectOption[]the options, in order
id?stringthe select's id; React's useId() when left out
name?stringwhat the choice is submitted as; the id when left out
placeholder?stringa first option with the value "", such as "Choose a country"
value?stringthe chosen value, for a controlled select (with onChange)
defaultValue?stringthe value chosen at first, for an uncontrolled one
onChange?(value: string) => voidcalled with the value chosen
onBlur?() => voidcalled when the select loses focus, to mark the field touched
autoComplete?stringthe autocomplete token, such as country or sex
hint?ReactNodehelp under the label
error?ReactNodewhat is wrong; marks the select invalid and describes it
required?booleanthe browser refuses the form while the value is ""
disabled?booleanshown but not changeable, and not submitted
className?stringadded to the wrapper's classes
rendersJSX.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";
impl/typescript.tsx · 52 lines · open · raw

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
Download for TypeScript react.form.select-1.0.0-typescript.fune · 17,564 bytes sha256 17c7b9c96e8322059e38ef7c3ee59485b36dea452124d3a5642811111d29a56c

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./react.form.select-1.0.0-typescript.fune, or fetch it from a terminal with fune pull react.form.select@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.select-1.0.0.fune, 17,536 bytes, sha256 5ca24bff280ebabaf901b8779e3d8a2c4aca46d21a8e39dc0ce02aa61ce07fa3.

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.

  1. 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>
  2. 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>
  3. 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>
  4. 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>
  5. 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>
  6. 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>
  7. 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>
  8. 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>
  9. 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>
  10. 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
  1. 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 &amp; A</label><select class="fune-select" id="e" name="e"><option value="a&quot;b">&lt;A&gt; &amp; B</option></select></div>
  2. 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>
  3. 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

  4. 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

  5. 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/.

Files

PathBytes
README.md2,581
impl/typescript.tsx2,125
vectors.json7,042