UI Component Implementation: Forms and States Questions
Implementing forms and the non-happy-path states of UI components in the browser. Covers form building (controlled and uncontrolled inputs, reusable form hooks tracking values, touched fields and errors, dynamic groups of repeating fields, multi-step flows, submit handling), client-side and server-side validation (inline errors, validation timing, cross-field rules, async validation such as username checks with stale-response handling, error summaries, mapping API error responses onto fields), and the states a component must handle: loading and pending, error and retry, empty results, disabled, error boundaries around a failing widget, and edge cases such as double submit, slow or failed networks, drafts preserved across failures, and unexpected input like pasted text or locale differences. Includes building custom inputs and widgets robustly: restricted numeric inputs, comboboxes and custom selects, and date fields with calendar popups, with labels, error association, focus management, announcing errors to assistive technology and keyboard operation. Excludes design-system library governance and tokens, optimistic-update and rollback logic in the client data layer, accessibility strategy as a design practice, and the state-management architecture of the whole application.
Write a React component that loads a list of users from GET /api/users and shows distinct loading, success and error states, with a way to retry after a failure. It must behave correctly if the user navigates away while the request is still running. Use fetch or axios with hooks.
Sample Answer
Keep one state value that is always exactly one of loading, success or error, start the request in a useEffect, and give that effect a cleanup function that cancels the request. Retry is a counter held in state: the effect depends on it, so bumping it re-runs the whole load. Cleanup runs when the user navigates away (the component unmounts) and also before the effect runs again, so a request that is no longer wanted can never write into the screen.
The component
import React, { useEffect, useState } from 'react';
export function UserList() {
const [state, setState] = useState({ status: 'loading' });
const [attempt, setAttempt] = useState(0); // bumping this re-runs the effect: that is the retry
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
(async () => {
try {
const res = await fetch('/api/users', { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch does not reject on 404/500
const users = await res.json();
if (controller.signal.aborted) return; // this effect run was cleaned up: drop the result
setState({ status: 'success', users });
} catch (err) {
if (controller.signal.aborted) return; // AbortError from our own cleanup is not a failure
setState({ status: 'error', message: err.message });
}
})();
return () => controller.abort();
}, [attempt]);
if (state.status === 'loading') return <p role="status">Loading users...</p>;
if (state.status === 'error') {
return (
<div role="alert">
<p>Could not load users.</p>
<button onClick={() => setAttempt((n) => n + 1)}>Retry</button>
</div>
);
}
if (state.users.length === 0) return <p>No users yet.</p>;
return <ul>{state.users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
Why the async work sits in an inner function: an async function always returns a promise, but React treats whatever the effect callback returns as the cleanup function. So the callback itself stays synchronous (it ends with return () => controller.abort()) and the awaiting happens in an immediately-invoked async arrow function, (async () => { ... })(), which is defined and called in one expression. react.dev's fetching example uses the same shape: an inner async function plus a cleanup that stops stale results from being used.
How each requirement is met:
- Loading, success, error and empty are separate renders. A fourth case matters: a successful response with zero users renders "No users yet.", not a blank list that looks like a failure or a hang. The loading line uses
role="status"and the error blockrole="alert", so assistive technology is told when the screen changes. fetchdoes not reject on HTTP errors. A 404 or 500 resolves normally; the promise rejects only on a network-level failure or an abort (MDN,Window.fetch()). Theres.okcheck turns those responses into errors. Without it, a 500 error page would be parsed as if it were user data, orres.json()would throw on an HTML body and be misreported.- Retry. Clicking "Retry" calls
setAttempt(n => n + 1). The effect listsattemptas a dependency, so React runs the old cleanup (a no-op by then) and starts a new request; the first line of the effect puts the UI back inloading. - Navigating away mid-request. The effect creates an
AbortController(the browser API for cancelling a request) and passes itssignaltofetch. The cleanup callscontroller.abort(), which makes the pendingfetchpromise reject with aDOMExceptionnamedAbortErrorand tells the browser to stop the request. That rejection lands in thecatch, so theif (controller.signal.aborted) return;guard there stops it from being shown as "Could not load users." - A second guard after the body is read. The guard after
await res.json()handles a response that arrives after cleanup, for example when the body is still being read or when a transport ignores the abort signal. react.dev's own fetching example uses a cleanup that sets anignoreflag so a stale result is never stored, and its async/await version still needs that cleanup; the second guard here is the same idea, expressed with the abort signal. Aborting stops the network work, and the guard keeps whatever still resolves from drawing.
Axios version
With axios the same shape applies. Axios accepts { signal: controller.signal } in the request config (supported in the browser and Node from v0.22.0), and a cancelled request rejects with axios.CanceledError, which axios.isCancel(err) recognises. Its CancelToken API is deprecated. Axios already rejects on non-2xx statuses by default, so the manual res.ok check is a fetch-specific step.
Proof by test
React 18's Strict Mode (a development-only wrapper, <StrictMode>, that stress-tests components) mounts each component, unmounts it and mounts it again to expose missing cleanup, which is the "double mount"; so two requests are visible in the Network tab in development and one in production (react.dev, "Synchronizing with Effects"). The tests use that: the first request must be aborted, and a slow stale response must not overwrite the fresh one. The fetch double (a test stand-in for the real fetch) lets the test settle each request by hand, so it can simulate a slow, failed or stale response on demand, and honours the abort signal like the real fetch; the last test uses a transport that ignores abort, so only the second guard can save the screen.
import assert from 'node:assert/strict';
import React, { StrictMode } from 'react';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserList } from './UserList.jsx';
const unhandled = [];
process.on('unhandledRejection', (e) => unhandled.push(e));
const ok = (data) => new Response(JSON.stringify(data), { status: 200 });
const tests = [];
const test = (name, fn) => tests.push([name, fn]);
// A fetch double whose promises the test settles by hand; honours the abort signal like the real one.
function controlledFetch() {
const calls = [];
globalThis.fetch = (url, { signal }) =>
new Promise((resolve, reject) => {
const call = { url, signal, resolve, reject };
signal.addEventListener('abort', () => reject(new DOMException('The operation was aborted.', 'AbortError')));
calls.push(call);
});
return calls;
}
test('loading -> success, and the empty list has its own state', async () => {
const calls = controlledFetch();
render(<UserList />);
assert.ok(screen.getByRole('status'));
calls[0].resolve(ok([{ id: 1, name: 'Ada' }, { id: 2, name: 'Linus' }]));
await screen.findByText('Ada');
assert.equal(screen.getAllByRole('listitem').length, 2);
cleanup();
const again = controlledFetch();
render(<UserList />);
again[0].resolve(ok([]));
await screen.findByText('No users yet.');
});
test('HTTP 500 and a network failure both land in error; Retry refetches and succeeds', async () => {
const calls = controlledFetch();
render(<UserList />);
calls[0].resolve(new Response(JSON.stringify([{ id: 9, name: 'Ghost' }]), { status: 500 })); // parseable body: only the res.ok check can reject it
await screen.findByRole('alert');
assert.equal(screen.queryByText('Ghost'), null);
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
assert.ok(screen.getByRole('status'));
calls[1].reject(new TypeError('Failed to fetch'));
await userEvent.click(await screen.findByRole('button', { name: 'Retry' }));
calls[2].resolve(ok([{ id: 1, name: 'Ada' }]));
await screen.findByText('Ada');
assert.equal(calls.length, 3);
});
test('unmount mid-request aborts it and leaves no unhandled rejection', async () => {
const calls = controlledFetch();
const { unmount } = render(<UserList />);
assert.equal(calls[0].signal.aborted, false);
unmount();
assert.equal(calls[0].signal.aborted, true);
await new Promise((r) => setTimeout(r, 20));
assert.equal(unhandled.length, 0);
});
test('StrictMode double mount: the first request is aborted and a late stale response cannot win', async () => {
const calls = controlledFetch();
// This double honours abort, so the stale call is already rejected; resolving it LAST is then a no-op, and the next test covers a transport that ignores abort.
render(<StrictMode><UserList /></StrictMode>);
assert.equal(calls.length, 2);
assert.equal(calls[0].signal.aborted, true);
assert.equal(calls[1].signal.aborted, false);
calls[1].resolve(ok([{ id: 2, name: 'Fresh' }]));
await screen.findByText('Fresh');
calls[0].resolve(ok([{ id: 1, name: 'Stale' }])); // too late: already rejected by abort, and guarded anyway
await new Promise((r) => setTimeout(r, 20));
assert.equal(Boolean(screen.queryByText('Stale')), false);
});
test('the aborted first request is not shown as an error while the fresh one is still loading', async () => {
const calls = controlledFetch();
render(<StrictMode><UserList /></StrictMode>);
assert.equal(calls[0].signal.aborted, true);
await new Promise((r) => setTimeout(r, 20)); // let the AbortError rejection reach the catch block
assert.equal(Boolean(screen.queryByRole('alert')), false);
assert.ok(screen.getByRole('status'));
calls[1].resolve(ok([{ id: 2, name: 'Fresh' }]));
await screen.findByText('Fresh');
});
test('a response that arrives after cleanup is dropped even when the transport ignores abort', async () => {
const calls = [];
globalThis.fetch = (url, { signal }) => new Promise((resolve) => calls.push({ signal, resolve })); // never rejects on abort
render(<StrictMode><UserList /></StrictMode>);
calls[1].resolve(ok([{ id: 2, name: 'Fresh' }]));
await screen.findByText('Fresh');
calls[0].resolve(ok([{ id: 1, name: 'Stale' }]));
await new Promise((r) => setTimeout(r, 20));
assert.equal(Boolean(screen.queryByText('Stale')), false);
assert.ok(screen.getByText('Fresh'));
});
let failed = 0;
for (const [name, fn] of tests) {
try { await Promise.race([fn(), new Promise((_, rej) => setTimeout(() => rej(new Error('timed out after 5s')), 5000))]); console.log('PASS', name); } catch (e) { failed++; console.log('FAIL', name, '-', e.message.split('\n')[0]); }
cleanup();
}
console.log(failed ? `${failed} failed` : 'all passed');
process.exit(failed ? 1 : 0);
Run it as described under Running the code; the printed lines are listed there.
Complexity and edge cases
- Cost. One request per mount or retry; the component holds one array in memory, and rendering is linear in the number of users. For thousands of rows add pagination or a virtualised list (one that renders only the visible rows).
- Rapid retries. The button only exists in the error state, and entering
loadingremoves it, so the user cannot stack retries on top of each other. - Unmounted component. React 18 no longer warns about setting state on an unmounted component, so silence there does not prove the code is right. The abort test checks the real signal instead.
- Response shape. If the endpoint can return something other than an array (such as
{ data: [...] }), validate it beforesetState; a malformed body should become the error state, not a render crash. - Where this pattern stops. Fetching in an effect gives no caching, no de-duplication across components and no server rendering. React's documentation points to a framework's data loading or a client cache such as TanStack Query for anything beyond a single screen like this; the effect version is still the baseline you are expected to write correctly.
Running the code
Save the component block as src/UserList.jsx and the test block as src/test.jsx, then add the jsdom setup and shell blocks below as src/setup.mjs and src/run.sh.
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true });
globalThis.HTMLElement = dom.window.HTMLElement;
cd /w && mkdir -p out && npx esbuild src/test.jsx --bundle --platform=node --format=esm --packages=external --loader:.js=jsx --outfile=out/t.mjs --log-level=error && node --import ./src/setup.mjs out/t.mjs 2>&1 | grep -E '^(PASS|FAIL|all passed|[0-9]+ failed)'
Run in a node:22 container (Node 22, React 18.3.1, jsdom 30), after npm i react@18 react-dom@18 @testing-library/react@14 @testing-library/user-event@14 jsdom esbuild in the mounted folder, with docker run --rm --ulimit core=0 -v "$PWD":/w -v "$PWD/src":/w/src:ro node:22 sh /w/src/run.sh, it prints:
PASS loading -> success, and the empty list has its own state
PASS HTTP 500 and a network failure both land in error; Retry refetches and succeeds
PASS unmount mid-request aborts it and leaves no unhandled rejection
PASS StrictMode double mount: the first request is aborted and a late stale response cannot win
PASS the aborted first request is not shown as an error while the fresh one is still loading
PASS a response that arrives after cleanup is dropped even when the transport ignores abort
all passed
The harness can fail: with the line return () => controller.abort(); deleted from the component, the unmount test, the Strict Mode test and the stale-response test each print FAIL ... Expected values to be strictly equal. With the guard after await res.json() deleted instead, only the last test prints FAIL. With the guard in the catch block deleted instead, only the error-flash test prints FAIL. With the if (!res.ok) throw ... line deleted, only the HTTP 500 test prints FAIL, because its 500 response carries a parseable JSON list that would otherwise be shown as users. The stale-text assertions compare a boolean rather than the element because a failing assert.equal on a jsdom node tries to print the whole DOM object, which can exhaust memory and kill the process instead of printing FAIL.
A form-heavy single-page app talks to an API that returns errors shaped like { error: { code: 'invalid_field', message: 'Invalid email', fields: { email: 'invalid_format' } } }. How do you turn those responses into what the user sees: per-field errors, form-level errors, translated text, and the difference between failures worth retrying and ones that are not? What do you do with server messages you do not want to show verbatim?
Sample Answer
Treat the server response as data, never as display text. Parse it into a small view model (a plain object holding exactly what the screen needs: field errors, an optional form-level error, a retryable flag) and render only strings from your own message catalog (a lookup table from keys to user-facing text, one table per language), chosen by the machine-readable code values. The server's message goes to logs, not to the screen.
Mapping layers
Take the response { error: { code: 'invalid_field', message: 'Invalid email', fields: { email: 'invalid_format' } } } through four steps:
- Get a result at all.
fetchrejects its promise only when the request fails outright (for example a network error); an HTTP 4xx or 5xx resolves normally and must be detected fromresponse.ok/response.status(MDN,Window.fetch()). So the wrapper turns a rejection into{ networkError: true }and a non-2xx response into{ status, body, retryAfter }. The body parse is wrapped intry/catchbecause proxies and gateways (servers that sit between the browser and the application and forward requests) often answer a 502 with an HTML page, not JSON. - Per-field errors. Each entry of
fieldsis a field name plus a code. Look up the text by the most specific key first (email.invalid_format), then by the bare code (required), then a generic "check this field" string. A field name the form does not render (the server blameszip, the UI has no zip input; the code calls these "orphans") cannot be shown beside an input, so it becomes a form-level error instead of vanishing.
For the sample response the result is a view model withfieldErrors.email= "Enter an email address like name@example.com.", no form-level error andretryablefalse (422 is not a retryable status). CallingtoViewModelon it with alogcallback gives{"fieldErrors":{"email":"Enter an email address like name@example.com."},"retryable":false}(undefined entries are dropped byJSON.stringify), and the log callback receives{"status":422,"code":"invalid_field","message":"Invalid email","orphans":[]}. The server's "Invalid email" never reaches the screen. - Form-level errors. Anything that is not about one input: 401 (session expired), 429 / 503 (service busy), a 5xx, or a 400/409/422 that carries no usable field errors. A 409 or 422 that does carry a field error (a taken email) shows only that field message, with no banner saying the failure was on the server's side. These render in one message region above the form.
- Translation. The catalog is keyed by locale, then by key. A missing key in the active locale falls back to English, and a missing key everywhere falls back to the generic string, so an unexpected new server code never shows a raw identifier.
Retryable and not retryable
"Retryable" here is a client policy: whether the UI offers "Try again" because repeating the same request can plausibly succeed.
| Failure | Offer retry? | Why |
|---|---|---|
| Network error (fetch rejected) | Yes | Nothing reached the server or no answer came back; the entered data is still in the form |
| 408, 502, 503, 504 | Yes | Timeout or upstream trouble, usually transient |
| 429 | Yes, after a wait | Rate limit. Retry-After is either a number of seconds or an HTTP date (MDN, Retry-After), and the code parses both |
| 400, 422 | No | The same input fails the same way; the user must edit a field |
| 401 | No | Retrying without signing in again fails; send the user to sign in |
| 409 | No | A conflict (such as a duplicate) needs a decision, not a repeat |
| 500 | No | Usually a deterministic server bug; show the generic message and a support reference |
The status classes in the table are exactly what the code below returns, and the test checks every row. Offering a button is different from retrying automatically. A form submit is a POST, and repeating a POST can create a duplicate record if the first request actually succeeded but the response was lost. Retry automatically only for GET requests, or for POSTs that carry a client-generated idempotency key (a unique token the server uses to recognise and ignore a repeated request). Otherwise let the user press the button.
Messages you do not want to show verbatim
Server message strings can leak internals ("pg pool exhausted at 10.0.3.7"), come in one language, change without notice, or contain text that was never reviewed. The rule is that only strings from your catalog reach the DOM. The raw code, message, status and request id (an identifier the server stamps on each response, here read from the X-Request-Id header) go to a log callback (telemetry: diagnostic data sent to a monitoring system), so support can match a user's report to a server log line. The test below asserts that both a raw validation message and a raw 503 message are absent from the page while the log still holds the first.
Working code
apiErrors.js is the mapping layer:
// Message catalogs: the ONLY text users see. Keys are `${field}.${code}` or a bare code.
export const CATALOG = {
en: {
'email.invalid_format': 'Enter an email address like name@example.com.',
'email.taken': 'That email is already registered. Try signing in.',
'password.too_short': 'Use at least 8 characters.',
required: 'This field is required.',
invalid_field: 'Check this field and try again.',
network: 'We could not reach the server. Check your connection and try again.',
busy: 'The service is busy. Try again in a moment.',
session: 'Your session expired. Sign in again.',
generic: 'Something went wrong on our side. Your entries are still here.',
},
es: {
'email.invalid_format': 'Introduce un correo como nombre@ejemplo.com.',
generic: 'Algo salio mal por nuestra parte. Tus datos siguen aqui.',
},
};
const RETRYABLE_STATUS = new Set([408, 429, 502, 503, 504]);
export function t(key, locale = 'en') {
return CATALOG[locale]?.[key] ?? CATALOG.en[key] ?? undefined;
}
// Retry-After is either delay-seconds or an HTTP-date (MDN / RFC 9110).
export function parseRetryAfter(value, now = Date.now()) {
if (value == null || value === '') return undefined;
if (/^\d+$/.test(value)) return Number(value) * 1000;
const at = Date.parse(value);
return Number.isNaN(at) ? undefined : Math.max(0, at - now);
}
// input: { networkError } or { status, body, retryAfter, requestId }
// returns a view model; err.message is never part of it.
export function toViewModel(input, knownFields, { locale = 'en', log = () => {}, now } = {}) {
if (input.networkError) {
return { fieldErrors: {}, formError: t('network', locale), retryable: true };
}
const err = input.body && typeof input.body === 'object' ? input.body.error : undefined;
const fields = err && typeof err.fields === 'object' && err.fields ? err.fields : {};
const fieldErrors = {};
const orphans = [];
for (const [name, code] of Object.entries(fields)) {
const text = t(`${name}.${code}`, locale) ?? t(String(code), locale) ?? t('invalid_field', locale);
if (knownFields.includes(name)) fieldErrors[name] = text;
else orphans.push(name); // server blames a field this form does not render
}
let formError;
if (input.status === 401) formError = t('session', locale);
else if (input.status === 429 || input.status === 503) formError = t('busy', locale);
else if (input.status === 400 || input.status === 409 || input.status === 422) {
if (Object.keys(fieldErrors).length === 0) formError = t('generic', locale);
} else formError = t('generic', locale);
if (orphans.length) formError = formError ?? t('generic', locale);
// The raw server text goes to telemetry only.
log({ status: input.status, code: err?.code, message: err?.message, orphans, requestId: input.requestId });
const retryable = RETRYABLE_STATUS.has(input.status);
return {
fieldErrors,
formError,
retryable,
retryAfterMs: retryable ? parseRetryAfter(input.retryAfter, now) : undefined,
};
}
// fetch wrapper: fetch rejects only on network failure, so HTTP errors are read from the Response.
export async function postJson(url, payload, fetchImpl = fetch) {
let res;
try {
res = await fetchImpl(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch {
return { networkError: true };
}
if (res.ok) return { ok: true };
let body;
try { body = await res.json(); } catch { body = undefined; } // proxies return HTML 502s
return { status: res.status, body, retryAfter: res.headers.get('Retry-After'), requestId: res.headers.get('X-Request-Id') };
}
SignupForm.jsx renders the view model. It moves keyboard focus to the first invalid input after a failed submit, links each message to its input with aria-describedby and sets aria-invalid="true" only after validation has run (MDN says to set it as the result of validation, not in the initial markup). The form-level region is a role="alert" container that is always present in the markup and has its text filled in later, which is the pattern MDN gives for getting a live announcement (a container added together with its text is generally not announced). The submit button is disabled while a request is pending, so a double click cannot send two requests:
import React, { useEffect, useId, useRef, useState } from 'react';
import { postJson, toViewModel } from './apiErrors.js';
const FIELDS = ['email', 'password'];
export function SignupForm({ locale = 'en', log }) {
const uid = useId();
const refs = { email: useRef(null), password: useRef(null) };
const [values, setValues] = useState({ email: '', password: '' });
const [vm, setVm] = useState({ fieldErrors: {}, formError: '', retryable: false });
const [pending, setPending] = useState(false);
const [done, setDone] = useState(false);
const [errorRound, setErrorRound] = useState(0);
// After a failed submit, move focus to the first invalid field in DOM order.
useEffect(() => {
const first = FIELDS.find((f) => vm.fieldErrors[f]);
if (first) refs[first].current.focus();
}, [errorRound]); // eslint-disable-line react-hooks/exhaustive-deps
async function onSubmit(e) {
e.preventDefault();
if (pending) return; // double-submit guard
setPending(true);
const result = await postJson('/api/signup', values);
setPending(false);
if (result.ok) return setDone(true);
setVm(toViewModel(result, FIELDS, { locale, log }));
setErrorRound((n) => n + 1);
}
const change = (name) => (e) => {
setValues((v) => ({ ...v, [name]: e.target.value }));
setVm((p) => ({ ...p, fieldErrors: { ...p.fieldErrors, [name]: undefined } }));
};
if (done) return <p>Account created.</p>;
return (
<form onSubmit={onSubmit} noValidate>
<div role="alert">{vm.formError}</div>
{FIELDS.map((name) => {
const err = vm.fieldErrors[name];
return (
<div key={name}>
<label htmlFor={`${uid}-${name}`}>{name}</label>
<input id={`${uid}-${name}`} ref={refs[name]} value={values[name]} onChange={change(name)}
type={name === 'password' ? 'password' : 'text'}
aria-invalid={err ? 'true' : undefined}
aria-describedby={err ? `${uid}-${name}-err` : undefined} />
{err && <p id={`${uid}-${name}-err`}>{err}</p>}
</div>
);
})}
<button type="submit" disabled={pending}>{pending ? 'Saving...' : vm.retryable ? 'Try again' : 'Sign up'}</button>
</form>
);
}
test.jsx renders the form with React 18 and Testing Library under jsdom and mocks fetch:
import assert from 'node:assert/strict';
import React from 'react';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupForm } from './SignupForm.jsx';
import { toViewModel, parseRetryAfter } from './apiErrors.js';
const json = (status, body, headers = {}) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });
const tests = [];
const test = (name, fn) => tests.push([name, fn]);
test('field error: translated text, aria wiring, focus, raw message hidden', async () => {
const logs = [];
globalThis.fetch = async () => json(422, { error: { code: 'invalid_field', message: 'Invalid email', fields: { email: 'invalid_format' } } });
render(<SignupForm log={(x) => logs.push(x)} />);
await userEvent.type(screen.getByLabelText('email'), 'nope');
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));
const input = await screen.findByLabelText('email');
const msg = await screen.findByText('Enter an email address like name@example.com.');
assert.equal(input.getAttribute('aria-invalid'), 'true');
assert.equal(input.getAttribute('aria-describedby'), msg.id);
assert.equal(document.activeElement, input);
assert.equal(screen.queryByText('Invalid email'), null);
assert.equal(logs[0].message, 'Invalid email'); // kept for telemetry only
assert.equal(screen.getByRole('alert').textContent, ''); // no form-level banner for pure field errors
});
test('503 + Retry-After: form-level message, retryable, button says Try again', async () => {
globalThis.fetch = async () => json(503, { error: { code: 'upstream', message: 'pg pool exhausted at 10.0.3.7' } }, { 'Retry-After': '30' });
render(<SignupForm />);
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));
await waitFor(() => assert.equal(screen.getByRole('alert').textContent, 'The service is busy. Try again in a moment.'));
assert.ok(screen.getByRole('button', { name: 'Try again' }));
assert.ok(!document.body.textContent.includes('pg pool'));
});
test('network failure keeps entries and is retryable; HTML 502 body does not crash', async () => {
let calls = 0;
globalThis.fetch = async () => { calls++; if (calls === 1) throw new TypeError('Failed to fetch'); return new Response('<html>Bad Gateway</html>', { status: 502 }); };
render(<SignupForm />);
await userEvent.type(screen.getByLabelText('email'), 'a@b.co');
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));
await waitFor(() => assert.match(screen.getByRole('alert').textContent, /could not reach the server/));
assert.equal(screen.getByLabelText('email').value, 'a@b.co');
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
await waitFor(() => assert.match(screen.getByRole('alert').textContent, /went wrong on our side/));
assert.equal(calls, 2);
});
test('pure mapping: unknown code, unknown field, locale fallback, retry classes, Retry-After', () => {
const known = ['email', 'password'];
const a = toViewModel({ status: 422, body: { error: { code: 'invalid_field', fields: { email: 'brand_new_code', zip: 'required' } } } }, known);
assert.equal(a.fieldErrors.email, 'Check this field and try again.');
assert.equal(a.formError, 'Something went wrong on our side. Your entries are still here.'); // zip is not rendered
assert.equal(toViewModel({ status: 422, body: { error: { fields: { email: 'invalid_format' } } } }, known, { locale: 'es' }).fieldErrors.email, 'Introduce un correo como nombre@ejemplo.com.');
const dup = toViewModel({ status: 409, body: { error: { code: 'conflict', fields: { email: 'taken' } } } }, known);
assert.equal(dup.fieldErrors.email, 'That email is already registered. Try signing in.');
assert.equal(dup.formError, undefined); // a duplicate is the user's to fix: no 'our side' banner on top of the field message
assert.equal(toViewModel({ status: 422, body: { error: { fields: { password: 'too_short' } } } }, known, { locale: 'es' }).fieldErrors.password, 'Use at least 8 characters.'); // falls back to en
const retry = Object.fromEntries([400, 401, 409, 422, 500, 408, 429, 502, 503, 504].map((s) => [s, toViewModel({ status: s }, known).retryable]));
assert.deepEqual(retry, { 400: false, 401: false, 409: false, 422: false, 500: false, 408: true, 429: true, 502: true, 503: true, 504: true });
assert.equal(parseRetryAfter('120'), 120000);
assert.equal(parseRetryAfter('Wed, 21 Oct 2015 07:28:00 GMT', Date.parse('Wed, 21 Oct 2015 07:27:00 GMT')), 60000);
assert.equal(parseRetryAfter('soon'), undefined);
assert.equal(toViewModel({ status: 429, retryAfter: '5' }, known).retryAfterMs, 5000);
});
let failed = 0;
for (const [name, fn] of tests) {
try { await fn(); console.log('PASS', name); } catch (e) { failed++; console.log('FAIL', name, '\n ', e.message.split('\n')[0]); }
cleanup();
}
console.log(failed ? `${failed} failed` : 'all passed');
process.exit(failed ? 1 : 0);
Trade-offs and pitfalls
- Codes beat messages as the contract. If the API only sends English
messagetext, the client cannot translate or branch on it. Ask for stable codes; where a field has no mapped code, the generic fallback keeps the UI honest. - Clear a field's error when the user edits that field, as
changedoes, so a stale message does not outlive the input it described. Keep every entry in the form on failure; wiping the form on an error is the commonest way to lose a user. - Do not set
aria-invalidon pristine fields. MDN advises against flagging empty required fields before the user has tried to submit. - Do not announce twice. Field errors are reached through focus plus
aria-describedby; therole="alert"region is for form-level text only, so a screen reader is not told the same failure by two channels. - Do not show
Retry-Afteras a raw number to users. Disable the button forretryAfterMs, or word it as "try again in a moment". - Server-side checks stay the authority. Client validation is a convenience; the mapping above exists because the server can still reject input the client accepted (a taken email, a changed rule).
Running the code
Save the files under src/ as apiErrors.js, SignupForm.jsx, test.jsx, setup.mjs and run.sh (the last two are scaffolding for running the test in Node, not part of the feature: setup.mjs installs jsdom globals, a simulated browser DOM, and run.sh bundles the JSX with esbuild and runs it):
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true });
globalThis.HTMLElement = dom.window.HTMLElement;
cd /w && mkdir -p out && npx esbuild src/test.jsx --bundle --platform=node --format=esm --packages=external --loader:.js=jsx --outfile=out/t.mjs --log-level=error && node --import ./src/setup.mjs out/t.mjs 2>&1 | grep -E '^(PASS|FAIL|all passed|[0-9]+ failed)'
Run in a node:22 container (Node 22, React 18.3.1, jsdom 30), after npm i react@18 react-dom@18 @testing-library/react@14 @testing-library/user-event@14 jsdom esbuild in the mounted folder, with docker run --rm --ulimit core=0 -v "$PWD":/w -v "$PWD/src":/w/src:ro node:22 sh /w/src/run.sh, it prints:
PASS field error: translated text, aria wiring, focus, raw message hidden
PASS 503 + Retry-After: form-level message, retryable, button says Try again
PASS network failure keeps entries and is retryable; HTML 502 body does not crash
PASS pure mapping: unknown code, unknown field, locale fallback, retry classes, Retry-After
all passed
The harness can fail: with the aria-describedby attribute and the focus call deleted from SignupForm.jsx, the first test prints FAIL and the run ends with 1 failed.
You are building a searchable select (combobox) in React where the user types to filter a list of options. How do you make it fully operable by keyboard and usable with a screen reader, and how would you verify that both manually and with automated tests?
Sample Answer
Direct answer
Build it on the WAI-ARIA Authoring Practices (APG) "combobox with list autocomplete" pattern: the text input gets role="combobox" (a text box that is paired with a pop-up list of choices), and the pop-up is a role="listbox" (a list of choices) of role="option" items (one choice each). The accessibility tree is the browser's second, simplified copy of the page that screen readers read; it lists each element with its role, its accessible name (the label text announced for it, such as "Country") and its states. Keyboard focus never leaves the input; the highlighted option is communicated with aria-activedescendant (the id of the option that is visually active), so the user keeps typing while arrowing through results. Verify with a layered plan: automated role and attribute assertions plus an axe-core rules run on every change, then a short manual keyboard-only pass, then a screen reader pass with at least one desktop pairing (a screen reader plus the browser it runs in, such as NVDA with Firefox: the same page can be spoken differently by different pairings), because automated tests cannot tell you what is spoken.
Roles, states and keys (from the APG example)
| Part | Markup | Notes |
|---|---|---|
| Input | role="combobox", aria-autocomplete="list", aria-controls (the listbox id), aria-expanded (true while the pop-up list is showing, false while it is closed), aria-activedescendant | A visible <label> provides the accessible name. |
| Pop-up | <ul role="listbox" aria-label="..."> | Labelled so it is not an anonymous list. |
| Items | <li role="option" id="...">, aria-selected="true" only on the highlighted one | The APG applies aria-selected only to the option referenced by aria-activedescendant. |
| Result count | <p role="status"> always in the DOM, text like "2 results available" | role="status" is a polite live region (spoken when the user pauses). It exists before its text changes, otherwise the change may not be announced. |
| Key | Behaviour implemented |
|---|---|
| Down Arrow | Closed: open and highlight the first option. Open: next option, wrapping from the last to the first. |
| Up Arrow | Closed: open and highlight the last option. Open: previous option, wrapping; with nothing highlighted yet, the last option. |
| Enter | With a highlighted option: put its text in the input, close the list, and do not submit the surrounding form. Without a highlight the key keeps its normal meaning. |
| Escape | List open: close it, keep the text. List closed: clear the text. |
| Typing | Filters the options and resets the highlight. The browser handles the caret keys. |
The mouse path matters too: mousedown on an option calls preventDefault() so the input does not lose focus (which would close the list before the click registers), and blur on the input closes the list. A sighted mouse user and a keyboard user end in the same state.
The wrap-around arithmetic, traced
The two arrow cases use modulo (%, the remainder after division) so the highlight wraps. Take the two results from typing au: Australia is index 0 and Austria is index 1, so results.length is 2.
| Key | Highlight before | Expression | Highlight after |
|---|---|---|---|
| Down | Austria (1) | (1 + 1) % 2 = 0 | Australia (0), wrapped to the first |
| Down | Australia (0) | (0 + 1) % 2 = 1 | Austria (1) |
| Up | Australia (0) | (0 - 1 + 2) % 2 = 1 | Austria (1), wrapped to the last |
| Up | Austria (1) | (1 - 1 + 2) % 2 = 0 | Australia (0) |
| Up | nothing highlighted (index -1) | no modulo: results.length - 1 = 1 | Austria (1), the last option |
The + results.length in the Up case matters because JavaScript's % keeps the sign of the left operand: (-1) % 2 is -1, not 1, which would point at no option. The values above were computed in a node:22 container. Nothing highlighted is index -1, and the modulo formula alone gets it wrong: (-1 - 1 + 2) % 2 is 0, the first option, not the last (with six results it lands on the fifth). So the Up branch tests active < 0 first, and check 7b below types a (six matches) and expects the last one, Canada; the single-formula version returns Brazil.
The component
import { useId, useMemo, useState, type KeyboardEvent } from 'react';
export type Option = { id: string; label: string };
export function Combobox({
label,
options,
onSelect,
labelled = true,
}: {
label: string;
options: Option[];
onSelect: (o: Option) => void;
labelled?: boolean; // false only exists so the automated check can be shown failing
}) {
const uid = useId();
const listId = `${uid}-list`;
const [text, setText] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1); // index of the visually highlighted option; DOM focus never leaves the input
const results = useMemo(
() => options.filter((o) => o.label.toLowerCase().includes(text.trim().toLowerCase())),
[options, text],
);
const showList = open && results.length > 0;
const optionId = (i: number) => `${uid}-opt-${i}`;
function choose(o: Option) {
setText(o.label);
setOpen(false);
setActive(-1);
onSelect(o);
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!showList) { setOpen(true); setActive(0); }
else setActive((active + 1) % results.length); // wraps from the last option to the first
break;
case 'ArrowUp':
e.preventDefault();
if (!showList) { setOpen(true); setActive(results.length - 1); }
else setActive(active < 0 ? results.length - 1 : (active - 1 + results.length) % results.length); // nothing highlighted yet: go to the last option
break;
case 'Enter':
if (showList && active >= 0) { e.preventDefault(); choose(results[active]); } // do not submit the surrounding form
break;
case 'Escape':
if (showList) { e.preventDefault(); setOpen(false); setActive(-1); }
else if (text) { e.preventDefault(); setText(''); }
break;
}
}
return (
<div>
{labelled && <label htmlFor={`${uid}-input`}>{label}</label>}
<input
id={`${uid}-input`}
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded={showList}
aria-controls={listId}
aria-activedescendant={showList && active >= 0 ? optionId(active) : undefined}
autoComplete="off"
value={text}
onChange={(e) => { setText(e.target.value); setOpen(true); setActive(-1); }}
onKeyDown={onKeyDown}
onBlur={() => { setOpen(false); setActive(-1); }}
/>
<ul id={listId} role="listbox" aria-label={label} hidden={!showList}>
{results.map((o, i) => (
<li
key={o.id}
id={optionId(i)}
role="option"
aria-selected={i === active}
onMouseDown={(e) => e.preventDefault()} // keep focus in the input while clicking
onClick={() => choose(o)}
>
{o.label}
</li>
))}
</ul>
{/* Always mounted so a screen reader hears the count change. */}
<p role="status">
{open && text ? (results.length ? `${results.length} ${results.length === 1 ? 'result' : 'results'} available` : 'No results found') : ''}
</p>
</div>
);
}
Automated verification
The tests use Testing Library's role queries (they find elements the way assistive technology does: by role and accessible name, so a missing label fails the test), simulated keyboard input, and axe-core, an accessibility rules engine, run on the DOM with the list open. The labelled prop exists only to show the axe check failing. The jsdom setup file is dom-env.mjs, listed under Running the code at the end.
import '../dom-env.mjs';
import assert from 'node:assert/strict';
import { render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { Combobox, type Option } from './Combobox.tsx';
const countries: Option[] = ['Argentina', 'Armenia', 'Australia', 'Austria', 'Brazil', 'Canada'].map((label) => ({ id: label.toLowerCase(), label }));
const user = userEvent.setup();
let picked: Option[] = [];
let submits = 0;
function mount(labelled = true) {
cleanup();
picked = [];
submits = 0;
render(
<main>
<form onSubmit={(e) => { e.preventDefault(); submits++; }}>
<Combobox label="Country" options={countries} onSelect={(o) => picked.push(o)} labelled={labelled} />
</form>
</main>,
);
return labelled ? screen.getByRole('combobox', { name: 'Country' }) : screen.getByRole('combobox');
}
const labels = () => screen.queryAllByRole('option').map((o) => o.textContent);
const activeLabel = (box: HTMLElement) => document.getElementById(box.getAttribute('aria-activedescendant') ?? '')?.textContent ?? null;
// 1. Name and initial state.
let box = mount();
assert.equal(box.getAttribute('aria-expanded'), 'false');
console.log('1 role=combobox, accessible name "Country", aria-expanded =', box.getAttribute('aria-expanded'));
// 2. Typing filters and announces the count.
await user.type(box, 'au');
assert.deepEqual(labels(), ['Australia', 'Austria']);
assert.equal(box.getAttribute('aria-expanded'), 'true');
assert.equal(screen.getByRole('status').textContent, '2 results available');
console.log('2 typed "au" ->', labels(), '| status:', JSON.stringify(screen.getByRole('status').textContent));
// 3. Arrow keys move the highlight; DOM focus stays on the input.
await user.keyboard('{ArrowDown}');
assert.equal(activeLabel(box), 'Australia');
assert.equal(document.activeElement, box);
assert.equal(screen.getByRole('option', { selected: true }).textContent, 'Australia');
await user.keyboard('{ArrowDown}{ArrowDown}'); // Austria, then wraps to Australia
assert.equal(activeLabel(box), 'Australia');
await user.keyboard('{ArrowUp}'); // wraps to the last option
assert.equal(activeLabel(box), 'Austria');
console.log('3 ArrowDown -> Australia | focus still on input | Down,Down wraps to Australia | Up wraps to', activeLabel(box));
// 4. Enter selects, closes, and does not submit the form.
await user.keyboard('{Enter}');
assert.equal((box as HTMLInputElement).value, 'Austria');
assert.equal(box.getAttribute('aria-expanded'), 'false');
assert.deepEqual(picked.map((p) => p.label), ['Austria']);
assert.equal(submits, 0);
console.log('4 Enter -> value', JSON.stringify((box as HTMLInputElement).value), '| expanded', box.getAttribute('aria-expanded'), '| form submits:', submits);
// 5. Escape closes the list first, clears the text second.
box = mount();
await user.type(box, 'ar');
await user.keyboard('{Escape}');
assert.equal(box.getAttribute('aria-expanded'), 'false');
assert.equal((box as HTMLInputElement).value, 'ar');
await user.keyboard('{Escape}');
assert.equal((box as HTMLInputElement).value, '');
console.log('5 Escape #1 closes (text kept "ar"), Escape #2 clears the text');
// 6. Mouse selection keeps focus in the input.
box = mount();
await user.type(box, 'a');
await user.click(screen.getByRole('option', { name: 'Armenia' }));
assert.equal((box as HTMLInputElement).value, 'Armenia');
assert.equal(document.activeElement, box);
console.log('6 click on "Armenia" -> value', JSON.stringify((box as HTMLInputElement).value), '| focus on input:', document.activeElement === box);
// 7. No matches.
box = mount();
await user.type(box, 'zzz');
assert.equal(screen.queryByRole('listbox'), null);
assert.equal(screen.getByRole('status').textContent, 'No results found');
console.log('7 typed "zzz" -> listbox hidden | status:', JSON.stringify(screen.getByRole('status').textContent));
// 7b. Up Arrow with the list open and nothing highlighted goes to the last option.
box = mount();
await user.type(box, 'a');
await user.keyboard('{ArrowUp}');
assert.equal(activeLabel(box), 'Canada');
console.log('7b typed "a" (6 results), ArrowUp with nothing highlighted ->', activeLabel(box));
// 8. Automated accessibility rules (axe-core) with the list open, then the same component with its label removed.
async function violations(labelled: boolean) {
const b = mount(labelled);
await user.type(b, 'au');
const result = await axe.run(document.body, { rules: { 'color-contrast': { enabled: false } } });
return result.violations.map((v) => v.id);
}
const good = await violations(true);
const bad = await violations(false);
assert.deepEqual(good, []);
assert.ok(bad.includes('label'));
console.log('8 axe-core violations with label:', good, '| with label removed:', bad);
Run in a node:22 container (node --import tsx combo/combo.test.tsx, React 18.3.1, axe-core 4.10.0, jsdom 25), it printed:
1 role=combobox, accessible name "Country", aria-expanded = false
2 typed "au" -> [ 'Australia', 'Austria' ] | status: "2 results available"
3 ArrowDown -> Australia | focus still on input | Down,Down wraps to Australia | Up wraps to Austria
4 Enter -> value "Austria" | expanded false | form submits: 0
5 Escape #1 closes (text kept "ar"), Escape #2 clears the text
6 click on "Armenia" -> value "Armenia" | focus on input: true
7 typed "zzz" -> listbox hidden | status: "No results found"
7b typed "a" (6 results), ArrowUp with nothing highlighted -> Canada
8 axe-core violations with label: [] | with label removed: [ 'label' ]
tsc --noEmit on the same files reports no type errors. The last line is the failure check: with the label removed, axe reports the label rule, so a green run means something. axe was run with its color-contrast rule off, because jsdom does no layout or painting, so contrast has to be checked in a real browser.
Manual verification (what the automated run cannot tell you)
- Keyboard only, mouse unplugged. Tab reaches the input once; typing filters; Down, Up, Enter and Escape behave as in the table; Shift+Tab leaves cleanly and the list closes; focus is always visible.
- Screen reader pass. Run the table's scenarios with a desktop screen reader and browser pairing such as NVDA with Firefox or Chrome on Windows, or VoiceOver with Safari on macOS. Check that the name and role are spoken on entry, that the highlighted option is spoken as the user arrows, and that the result count is spoken after typing. The APG documents the pattern's intended behaviour; actual speech varies between screen reader and browser pairings, so test the pairings your users have (the combinations of screen reader and browser they actually use).
- Zoom and high contrast. At 200% zoom and in forced-colors mode, the highlighted option must be distinguishable without relying on color alone.
Extensions, as depth follow-ups
Each item below adds to the base component and is a direction an interviewer may take the conversation. Terms used: virtualised means only the rows currently on screen exist in the DOM; forced-colors mode is a browser mode (for example Windows contrast themes) where the browser replaces your colors with a small palette the user chose.
- Options loaded asynchronously. Fetch with an
AbortControllerper keystroke burst and ignore stale answers; keep the status region saying "Loading" and then the result count; keep the list stable (do not clear it before new results arrive) to avoid flicker. - Virtualised long lists (rendering only the visible rows). Each rendered option carries
aria-setsize(the total) andaria-posinset(its position), as MDN documents for partly-rendered listboxes;-1foraria-setsizemeans the total is unknown. The option referenced byaria-activedescendantmust actually exist in the DOM, so render the active row and scroll it into view withscrollIntoView({ block: 'nearest' }). - Tagging (multi-select). Selected values become chips next to the input, each with a remove button whose label names the item ("Remove Armenia"); Backspace on an empty input removes the last chip; the result count and live region still announce additions and removals.
- Touch. Tapping an option must work (the component handles click), and option rows should meet WCAG 2.2 success criterion 2.5.8 (Level AA): targets at least 24 by 24 CSS pixels unless an exception applies. Test on real phones, because mobile screen readers and virtual keyboards are not covered by this setup.
Pitfalls
- Moving real DOM focus into the list: the user can no longer keep typing, and every arrow press loses the caret.
- Using
<select>styling hacks instead of the pattern: loses typing and the filtered count. - Setting
aria-selectedon every option as the user hovers; it should reflect the single active descendant. - Closing the list on
blurwithoutmousedownhandling, which swallows the click. - Announcing every keystroke with
aria-live="assertive"; the polite status message after the user pauses is enough.
Running the code
Everything runs from one project root in a node:22 container. Files: dom-env.mjs, package.json, tsconfig.json and combo/Combobox.tsx and combo/combo.test.tsx (the component and the test above).
npm i react@18.3.1 react-dom@18.3.1 @testing-library/react@16 @testing-library/dom@10 @testing-library/user-event@14 jsdom@25 tsx typescript@5 @types/react@18 @types/react-dom@18 @types/node@22 axe-core@4.10.0
node --import tsx combo/combo.test.tsx
npx tsc --noEmit
dom-env.mjs (copies the jsdom window's globals onto Node's global object):
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/', pretendToBeVisual: true });
for (const k of Object.getOwnPropertyNames(window)) if (!(k in globalThis)) Object.defineProperty(globalThis, k, { configurable: true, get: () => window[k] });
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
package.json:
{ "type": "module" }
tsconfig.json:
{"compilerOptions":{"jsx":"react-jsx","strict":true,"module":"esnext","moduleResolution":"bundler","target":"es2022","allowImportingTsExtensions":true,"noEmit":true,"skipLibCheck":true}}
Without "type": "module" the test does not compile (esbuild, used by tsx, reports Top-level await is currently not supported with the "cjs" output format); without "jsx": "react-jsx" the JSX becomes React.createElement calls and the run stops with ReferenceError: React is not defined.
You are implementing a date field with a calendar popup for a booking form. What edge cases do you plan for before calling it done, and how do you handle each: users in different locales, typed rather than picked dates, invalid or out-of-range dates, keyboard and touch use, and a calendar that must render many dates?
Sample Answer
Direct answer
Before calling a date field done I would settle five things. (1) Store and send the date as a plain calendar date in ISO yyyy-mm-dd form, and format it for display with Intl.DateTimeFormat, so locale only changes what the user sees. (2) Default to the native <input type="date"> with min and max, because it gives locale order, keyboard entry and the platform's touch picker for free, and build a custom calendar only when the booking needs something the native picker cannot do (blocked days, a price per night, a two-date range). (3) Treat every typed value as untrusted: reject impossible dates such as 30 February, treat a year typed halfway (such as 0020) as invalid, and check the range yourself as well as through the browser. (4) Make the calendar a keyboard-operable grid with focus returned to the trigger. (5) Render only the visible month and fetch availability per month.
Lead with (1) and (3): they decide whether the stored data is correct, while (4) and (5) are about how the control feels and scales. Terms used here: ISO yyyy-mm-dd is the international year-month-day format (ISO 8601), Intl.DateTimeFormat is the built-in browser and Node API that formats dates for a locale, and UTC is the zero-offset reference time zone.
Locales: values are ISO, display is local
<input type="date"> always exposes .value as yyyy-mm-dd, whatever order the user sees (MDN). Playwright 1.48.2 with the en-US and de-DE locales in Chromium, Firefox and WebKit all returned 2026-05-09 for the same field, even though the visible order differs. So the submitted value is never locale-dependent; only a custom text field has to know the order. The code below reads the order from Intl.DateTimeFormat(...).formatToParts rather than hard-coding it, because 03/04/2026 is 4 March in the US and 3 April in Great Britain.
The classic time zone bug is new Date('2026-03-01'): a date-only ISO string is parsed as midnight UTC, so a user west of UTC sees the day before. Traced for Los Angeles in early March (8 hours behind UTC): midnight UTC on 1 March is 4 pm on 28 February on a Los Angeles clock, so toLocaleDateString prints 2/28/2026, which is the second line of the run below (the first prints the time zone). The code stores {y, m, d}, never a Date, and formats with timeZone: 'UTC' on a UTC-built Date, which cannot shift. The week also starts on a different day by locale: Intl.Locale#getWeekInfo().firstDay (1 is Monday, 7 is Sunday) says which, with an older weekInfo getter in some engines. (formatToParts returns the formatted date as a list of {type, value} pieces such as {type: 'month', value: '03'}, which is how the code learns the field order.)
Typed, invalid and out-of-range dates
| Case | What to do |
|---|---|
| Impossible date (30 February, 31 April) | Re-build the date and compare the parts. new Date('2026-02-30') does not reject it in V8 (the JavaScript engine of Chrome and Node; it is a valid object, rolled into March), so a "did it parse" test passes wrongly. |
| Half-typed date | In the native control value is '' until the month, day and at least one digit of the year are filled, so valueMissing stays true until then. After that value is already a complete date with a tiny year. In Playwright 1.48.2 (10 runs per browser, identical every run), typing the keys 0, 5, 0, 9, 2, 0, 2, 6 into an en-US required date field in Chromium and Firefox gave value '' after each of the first four keys, 0002-05-09 after the fifth, 0020-05-09 after the sixth, 0202-05-09 after the seventh and 2026-05-09 after the eighth, with validity.valid true from the fifth key on. badInput was true after the second, third and fourth keys (a segment in progress) and false once a value existed. In the WebKit build the keyboard did not fill the date segments at all (value stayed ''), so none of this could be reproduced there. So a year typed halfway is a valid-looking value: set min, so that 0020-05-09 fails with rangeUnderflow, and check the year yourself. Do not rely on badInput alone: badInput and valueMissing can both be true at once, and support for the half-typed state varies by browser, so check value === '' and show the message yourself. Show "Enter a full date" on blur or submit, not while typing. |
Before min / after max | Set min/max on the input (MDN: the picker disables out-of-range days, and a value before min sets validity.rangeUnderflow, which Chromium, Firefox and WebKit all reported in a Playwright run), and also compare in code, because a custom text field has no constraint validation and the server must re-check anyway. |
| Two-digit years, other separators | Accept /, . and - as separators; reject 4/3/26 with a format message rather than guessing the century. |
| Unavailable days (fully booked) | Mark the cell aria-disabled="true" and keep it focusable so arrow-key movement is not interrupted; say why in the cell's label ("14 March, fully booked"). |
Keyboard and touch
For a custom picker follow the WAI-ARIA Authoring Practices date picker dialog: the trigger opens a role="dialog" containing a role="grid" with a roving tabindex (exactly one cell is in the Tab order). In plain terms: role="dialog" marks a layer over the page that keeps focus inside it; role="grid" marks a two-dimensional set of cells moved through with arrow keys; roving tabindex means the one cell that currently has focus gets tabindex="0" and every other cell tabindex="-1", and the script swaps them as focus moves, so Tab passes through the whole grid in one stop; and aria-live="polite" makes a screen reader announce changed text when it next pauses. A minimal skeleton (WAI-ARIA Authoring Practices date picker dialog pattern; exactly 1 of the 3 cells, the one with tabindex="0", is in the Tab order):
<button aria-haspopup="dialog" aria-expanded="true">Choose date</button>
<div role="dialog" aria-modal="true" aria-labelledby="month">
<h2 id="month" aria-live="polite">March 2026</h2>
<table role="grid" aria-labelledby="month">
<tr>
<td role="gridcell" tabindex="-1">28</td>
<td role="gridcell" tabindex="0" aria-selected="true">1</td>
<td role="gridcell" tabindex="-1" aria-disabled="true">2</td>
</tr>
</table>
</div>
Arrow keys move by day or week, Home and End go to the start or end of the week, Page Up and Page Down change month (Shift for year), Enter or Space selects and closes, Escape closes and returns focus to the trigger, and the month heading is aria-live="polite" so the change is announced. The text input stays usable beside the button, which is what makes typing a first-class path rather than a fallback. On touch, prefer the native picker (type="date") because the operating system renders its own wheel or calendar; a custom grid needs large tap targets and must not rely on hover.
A calendar that must render many dates
A month never needs more than 42 cells (6 weeks of 7), so render the visible month only, not the whole booking window. Fetch availability per month and prefetch the next one. The last lines of the run below show the cell count depends on the locale's first weekday: February 2026 is exactly 4 weeks with a Sunday start and 5 with a Monday start. The arithmetic in monthGrid: 1 February 2026 is a Sunday (getUTCDay 0) and the month has 28 days. With a Sunday start (en-US) the leading blanks are (0 - 0 + 7) % 7 = 0, so cells = ceil(28 / 7) x 7 = 28, 4 weeks. With a Monday start (en-GB) the leading blanks are (0 - 1 + 7) % 7 = 6, so cells = ceil((6 + 28) / 7) x 7 = 5 x 7 = 35, 5 weeks. If a design shows 12 months at once, that is up to 12 x 42 = 504 cells; at that point render months lazily as they scroll into view (virtualisation) rather than building all of them.
Code and run
// Calendar dates are {y, m, d} values, never Date objects, so no time zone can shift them.
export function isValidYmd(y, m, d) {
const t = new Date(Date.UTC(y, m - 1, d));
return y >= 1000 && t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
}
export function toIso({ y, m, d }) {
return `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
// Strict yyyy-mm-dd, the format <input type="date"> uses for .value in every locale.
export function parseIso(s) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
const [y, mo, d] = [+m[1], +m[2], +m[3]];
return isValidYmd(y, mo, d) ? { y, m: mo, d } : null;
}
// Field order for a locale, read from Intl instead of hard-coded: ['month','day','year'] for en-US.
export function fieldOrder(locale) {
return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit' })
.formatToParts(new Date(Date.UTC(2026, 10, 25)))
.filter((p) => ['day', 'month', 'year'].includes(p.type))
.map((p) => p.type);
}
// Typed text such as "05/09/2026" or "9.5.2026", interpreted in the locale's field order.
export function parseTyped(text, locale) {
const parts = text.trim().split(/[^0-9]+/).filter(Boolean);
if (parts.length !== 3 || !/^\d+$/.test(parts.join(''))) return { error: 'format' };
const v = {};
fieldOrder(locale).forEach((k, i) => (v[k] = +parts[i]));
if (String(parts[fieldOrder(locale).indexOf('year')]).length !== 4) return { error: 'format' };
return isValidYmd(v.year, v.month, v.day) ? { date: { y: v.year, m: v.month, d: v.day } } : { error: 'impossible' };
}
export function checkRange(date, minIso, maxIso) {
const iso = toIso(date);
if (minIso && iso < minIso) return 'before-min';
if (maxIso && iso > maxIso) return 'after-max';
return null; // ISO strings of equal length sort chronologically
}
export function display(date, locale) {
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' })
.format(new Date(Date.UTC(date.y, date.m - 1, date.d)));
}
// Only the visible month is rendered: leading blanks + days, padded to whole weeks.
export function monthGrid(y, m, locale) {
const loc = new Intl.Locale(locale);
const info = loc.getWeekInfo ? loc.getWeekInfo() : loc.weekInfo; // older engines expose a weekInfo getter
const first = info.firstDay % 7; // Intl: 1=Mon..7=Sun, getUTCDay: 0=Sun
const days = new Date(Date.UTC(y, m, 0)).getUTCDate();
const lead = (new Date(Date.UTC(y, m - 1, 1)).getUTCDay() - first + 7) % 7;
const cells = Math.ceil((lead + days) / 7) * 7;
return { lead, days, cells, weeks: cells / 7 };
}
import assert from 'node:assert/strict';
import { parseIso, parseTyped, checkRange, display, fieldOrder, monthGrid, toIso } from './date-utils.mjs';
console.log('TZ =', process.env.TZ);
// 1. The classic bug: new Date('2026-03-01') is UTC midnight, so west of UTC it prints the previous day.
console.log('new Date("2026-03-01").toLocaleDateString("en-US"):', new Date('2026-03-01').toLocaleDateString('en-US'));
console.log('display({2026,3,1}, "en-US"):', display({ y: 2026, m: 3, d: 1 }, 'en-US'));
assert.equal(display({ y: 2026, m: 3, d: 1 }, 'en-US'), 'March 1, 2026');
// 2. Impossible and incomplete dates
console.log('Date("2026-02-30") valid?', !Number.isNaN(new Date('2026-02-30').getTime()), '| parseIso("2026-02-30"):', parseIso('2026-02-30'));
assert.equal(parseIso('2026-02-30'), null);
assert.deepEqual(parseIso('2028-02-29'), { y: 2028, m: 2, d: 29 });
assert.equal(parseIso('2026-02-29'), null);
// 3. The same keystrokes mean different dates per locale
console.log(fieldOrder('en-US'), fieldOrder('de-DE'), fieldOrder('ja-JP'));
console.log('"03/04/2026" en-US ->', toIso(parseTyped('03/04/2026', 'en-US').date), '| en-GB ->', toIso(parseTyped('03/04/2026', 'en-GB').date));
console.log('"31.04.2026" de-DE ->', JSON.stringify(parseTyped('31.04.2026', 'de-DE')));
console.log('"4/3/26" en-US ->', JSON.stringify(parseTyped('4/3/26', 'en-US')));
assert.equal(toIso(parseTyped('03/04/2026', 'en-US').date), '2026-03-04');
assert.equal(toIso(parseTyped('03/04/2026', 'en-GB').date), '2026-04-03');
assert.equal(parseTyped('31.04.2026', 'de-DE').error, 'impossible');
// 4. Range
console.log(checkRange({ y: 2026, m: 2, d: 15 }, '2026-03-01', '2026-12-31'), checkRange({ y: 2026, m: 5, d: 9 }, '2026-03-01', '2026-12-31'));
assert.equal(checkRange({ y: 2027, m: 1, d: 1 }, '2026-03-01', '2026-12-31'), 'after-max');
// 5. Calendar size: cells rendered per month depends on the locale's first weekday
for (const loc of ['en-US', 'en-GB']) {
const rows = [];
for (let m = 1; m <= 12; m++) rows.push(monthGrid(2026, m, loc).weeks);
console.log(loc, 'weeks per month in 2026:', rows.join(','), '| max cells:', Math.max(...rows) * 7);
}
console.log('Feb 2026 en-US', monthGrid(2026, 2, 'en-US'), 'en-GB', monthGrid(2026, 2, 'en-GB'));
console.log('ALL ASSERTIONS PASSED');
Saved as date-utils.mjs and demo.mjs in one folder and run with env TZ=America/Los_Angeles node demo.mjs in a node:22 container (Node 22.23.3), it prints:
TZ = America/Los_Angeles
new Date("2026-03-01").toLocaleDateString("en-US"): 2/28/2026
display({2026,3,1}, "en-US"): March 1, 2026
Date("2026-02-30") valid? true | parseIso("2026-02-30"): null
[ 'month', 'day', 'year' ] [ 'day', 'month', 'year' ] [ 'year', 'month', 'day' ]
"03/04/2026" en-US -> 2026-03-04 | en-GB -> 2026-04-03
"31.04.2026" de-DE -> {"error":"impossible"}
"4/3/26" en-US -> {"error":"format"}
before-min null
en-US weeks per month in 2026: 5,4,5,5,6,5,5,6,5,5,5,5 | max cells: 42
en-GB weeks per month in 2026: 5,5,6,5,5,5,5,6,5,5,6,5 | max cells: 42
Feb 2026 en-US { lead: 0, days: 28, cells: 28, weeks: 4 } en-GB { lead: 6, days: 28, cells: 35, weeks: 5 }
ALL ASSERTIONS PASSED
With TZ=Pacific/Auckland (east of UTC) the naive line prints 3/1/2026 and the others are unchanged, so the bug only appears for some users, which is why it survives code review done in one time zone.
Trade-offs
- Native versus custom: native wins on accessibility, locale and mobile effort; custom wins when the booking logic (blocked dates, range highlighting, prices) must be visible in the grid. Commit to native unless that requirement exists.
- Validate when: on blur and on submit, not on each keystroke, since a date is invalid until the last digit is typed.
- Pitfall: treating the server as optional. A client check protects the user's time; the server re-validates the range, the availability and the format, because anything can be posted.
Explain the difference between controlled and uncontrolled form inputs in React: how each affects event handling, keeping state in sync and performance. Convert an input that uses a ref into a controlled one, and say when you would pick each approach.
Sample Answer
Direct answer
A controlled input is one whose current value is owned by React state: you pass value and an onChange handler, and React forces the input to always show what the state says. An uncontrolled input keeps its value in the browser's DOM; you give it at most a starting value with defaultValue, and read the current value when you need it (through a ref, or FormData on submit). Controlled gives you the value on every keystroke, which is what live validation, formatting, dependent fields and "disable the button until valid" need, at the cost of a re-render per keystroke. Uncontrolled is less code and renders nothing while typing, which suits simple forms that only need the values at submit. Pick controlled when the UI must react to the value as it changes, uncontrolled when it only needs it once.
How each one works
Event handling. Controlled: every change fires onChange, you call the state setter, React re-renders and writes the new value into the DOM input. If you pass value and no onChange, the field becomes read-only and React logs a warning; React's own docs call this out (use defaultValue, onChange, or readOnly). Uncontrolled: the browser edits its own copy; your code does nothing during typing and reads inputRef.current.value (or a FormData) at the moment it needs it.
Keeping state in sync. A controlled input has one source of truth (the single place that holds the authoritative value; here the React state), so a "Reset" button, a value loaded from the server or a formatter (strip non-digits) is just a state update. For an uncontrolled input the DOM is the source of truth; resetting means calling form.reset() or setting ref.current.value imperatively (by issuing a direct command to the DOM node instead of describing the value through state), and there is no state to render from.
Performance. Controlled re-renders the component (and its children, unless they are memoized, i.e. wrapped with React.memo so React skips re-rendering them when their props did not change) on each keystroke. Uncontrolled does not. The proof test under "Proof" below counts renders deterministically rather than timing them. Its console output is reproduced here; the two warning lines are deliberately cut to their first 110 to 118 characters by the test's slice so they fit on one line:
renders after typing 5 characters: { uncontrolled: 1, controlled: 6 }
saved: [["uncontrolled","Adahello"],["controlled","Adahello"]]
FormData result: {"first":"Ada","last":"Lovelace","subscribe":"on"}
warning: Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing f
stuck warning: Warning: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-on
Typing 5 characters costs 5 extra renders when controlled and none when uncontrolled. For a small form this is invisible; it matters when the form has many fields or expensive children, in which case keep state local to each field component, memoize the heavy children, or make the rarely-needed fields uncontrolled.
Converting a ref input to a controlled one
Steps: replace the useRef with useState seeded with the old defaultValue; pass value={name}; add onChange={(e) => setName(e.target.value)}; read the state (not the ref) on submit. The two components below are the same form before and after (components 1 and 2 in the code).
import { useEffect, useRef, useState } from 'react';
// 1. Uncontrolled: the DOM owns the value; React reads it through a ref when it needs it.
export function UncontrolledName({ onSave, counter }) {
counter.renders += 1;
const inputRef = useRef(null);
return (
<form onSubmit={(e) => { e.preventDefault(); onSave(inputRef.current.value); }}>
<label htmlFor="n1">Name</label>
<input id="n1" ref={inputRef} defaultValue="Ada" />
<button>Save</button>
</form>
);
}
// 2. The same form converted to controlled: React state owns the value, the input only displays it.
export function ControlledName({ onSave, counter }) {
counter.renders += 1;
const [name, setName] = useState('Ada');
return (
<form onSubmit={(e) => { e.preventDefault(); onSave(name); }}>
<label htmlFor="n2">Name</label>
<input id="n2" value={name} onChange={(e) => setName(e.target.value)} />
<button>Save</button>
</form>
);
}
// 3. Uncontrolled with several fields: read everything at once with FormData (every input needs a name).
export function FormDataForm({ onSave }) {
return (
<form onSubmit={(e) => { e.preventDefault(); onSave(Object.fromEntries(new FormData(e.currentTarget))); }}>
<label htmlFor="fn">First</label><input id="fn" name="first" defaultValue="Ada" />
<label htmlFor="ln">Last</label><input id="ln" name="last" defaultValue="Lovelace" />
<label htmlFor="ok">Subscribe</label><input id="ok" name="subscribe" type="checkbox" />
<button>Save</button>
</form>
);
}
// 4. A controlled input whose value arrives later: undefined first, then a string.
export function LoadedName({ loadName, initial }) {
const [name, setName] = useState(initial); // undefined: React treats the input as uncontrolled
useEffect(() => { loadName().then(setName); }, [loadName]);
return <input aria-label="Loaded name" value={name} onChange={(e) => setName(e.target.value)} />;
}
// 5. value without onChange: React makes the field read-only and warns.
export function StuckInput() {
return <input aria-label="Stuck" value="fixed" />;
}
Proof, rendered with React 18 and Testing Library under jsdom:
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UncontrolledName, ControlledName, FormDataForm, LoadedName, StuckInput } from './forms.jsx';
afterEach(cleanup);
describe('controlled vs uncontrolled', () => {
it('renders once per keystroke when controlled, and not at all after mount when uncontrolled', async () => {
const user = userEvent.setup();
const a = { renders: 0 }; const b = { renders: 0 };
const saved = [];
const { unmount } = render(<UncontrolledName counter={a} onSave={(v) => saved.push(['uncontrolled', v])} />);
await user.type(screen.getByLabelText('Name'), 'hello');
await user.click(screen.getByRole('button'));
unmount();
render(<ControlledName counter={b} onSave={(v) => saved.push(['controlled', v])} />);
await user.type(screen.getByLabelText('Name'), 'hello');
await user.click(screen.getByRole('button'));
console.log('renders after typing 5 characters:', { uncontrolled: a.renders, controlled: b.renders });
console.log('saved:', JSON.stringify(saved));
expect(a.renders).toBe(1);
expect(b.renders).toBe(6); // 1 mount + 5 keystrokes
expect(saved).toEqual([['uncontrolled', 'Adahello'], ['controlled', 'Adahello']]);
});
it('reads several uncontrolled fields at once with FormData', async () => {
const user = userEvent.setup();
let got;
render(<FormDataForm onSave={(v) => (got = v)} />);
await user.click(screen.getByLabelText('Subscribe'));
await user.click(screen.getByRole('button'));
console.log('FormData result:', JSON.stringify(got));
expect(got).toEqual({ first: 'Ada', last: 'Lovelace', subscribe: 'on' });
});
it('does not warn when the value starts as an empty string, and warns when it starts as undefined', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const loadName = () => Promise.resolve('Ada');
// The fixed version goes first: React reports this particular warning only once per page load.
await act(async () => { render(<LoadedName loadName={loadName} initial="" />); });
expect(spy).not.toHaveBeenCalled();
expect(screen.getByLabelText('Loaded name').value).toBe('Ada');
cleanup();
await act(async () => { render(<LoadedName loadName={loadName} initial={undefined} />); });
const warning = spy.mock.calls.map((c) => String(c[0])).find((m) => m.includes('changing an uncontrolled input to be controlled'));
console.log('warning:', warning.split('\n')[0].slice(0, 118));
expect(warning).toBeTruthy();
expect(screen.getByLabelText('Loaded name').value).toBe('Ada');
spy.mockRestore();
});
it('value without onChange makes the input read-only and React says so', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const user = userEvent.setup();
render(<StuckInput />);
await user.type(screen.getByLabelText('Stuck'), 'abc');
expect(screen.getByLabelText('Stuck').value).toBe('fixed');
const msg = spy.mock.calls.map((c) => String(c[0])).find((m) => m.includes('without an `onChange` handler'));
console.log('stuck warning:', msg.slice(0, 110));
expect(msg).toBeTruthy();
spy.mockRestore();
});
});
Switch warning and FormData
- Uncontrolled to controlled warning. React treats an input as controlled when it receives a string
valueand as uncontrolled whenvalueisundefined; an input cannot change from one to the other during its lifetime. The common cause is state that startsundefined(data not loaded yet) and later becomes a string, which logs "A component is changing an uncontrolled input to be controlled". The fix, shown byLoadedNamewithinitial="", is to keep the value a string from the first render (useState(user?.name ?? '')). React's documentation gives the same fallback pattern (value={someValue ?? ''}). - FormData. For an uncontrolled form,
new FormData(e.currentTarget)reads every named field at once; every input needs aname, andObject.fromEntriesturns it into an object. A checked checkbox with novalueattribute comes through as'on', as the output above shows (subscribe: "on").
Advantages of controlled components
- Instant validation and formatting as the user types, because the value is available in each render.
- Conditional UI from the value: enable or disable a button, show a character count, reveal a dependent field.
- One source of truth, so programmatic changes (reset, prefill, "copy billing address") are plain state updates.
- Easy to test and to keep in sync with other state (URL, a store, a server draft).
- The component fully decides what the input may contain, for example rejecting characters by not storing them.
Trade-offs
- Choose controlled for live validation, masks, dependent fields and anything the UI shows from the value; choose uncontrolled for a plain submit-only form, or a large form where per-keystroke renders matter.
- Pitfalls:
valuewithoutonChange;value={undefined}ornullat first; passing bothvalueanddefaultValue(React's docs: an input cannot be both controlled and uncontrolled); storing derived values in state instead of computing them.
Running the code
Save the first code block above as forms.jsx, the second as controlled.test.jsx, and put this vitest.config.mjs beside them (the files use JSX without importing React and the tests need a DOM, so without it they fail with a missing document):
import { defineConfig } from 'vitest/config';
export default defineConfig({ esbuild: { jsx: 'automatic' }, test: { environment: 'jsdom' } });
npm i react@18 react-dom@18 @testing-library/react@14 @testing-library/user-event@14 jsdom vitest@2.1.8
npx vitest run controlled
Run in a node:22 container (React 18.3.1, jsdom 30), this prints 4 passing tests and the lines logged above. Both forms save the same value, Adahello, after typing "hello" onto the starting value "Ada".
Unlock Full Question Bank
Get access to all 18 UI Component Implementation: Forms and States interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.