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.
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.
That is every published UI Component Implementation: Forms and States question for Software Engineer so far. Browse the other topics in this category, or practice this one interactively.