Runnable lesson · press Try it on the examples to edit and run them here.
Every field needs the same ingredients: a label, an input, a hint, an error message and the right ARIA attributes. Writing that by hand for every field is repetitive and easy to get wrong. A few small components fix it.
A TextField
function TextField({ label, error, hint, ...inputProps }) {
const id = React.useId(); // unique id per instance
const hintId = hint ? id + "-hint" : undefined;
const errorId = error ? id + "-error" : undefined;
return (
<div style={{ marginBottom: 12 }}>
<label htmlFor={id} style={{ display: "block", fontWeight: 600 }}>{label}</label>
<input
id={id}
aria-label={label}
aria-invalid={error ? "true" : undefined}
aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
{...inputProps}
/>
{hint && <small id={hintId} style={{ display: "block", color: "#555" }}>{hint}</small>}
{error && <small id={errorId} role="alert" style={{ display: "block", color: "#b71c1c" }}>{error}</small>}
</div>
);
}
function App() {
const [name, setName] = React.useState("");
return (
<div>
<TextField
label="Full name"
hint="As shown on your ID"
value={name}
onChange={(e) => setName(e.target.value)}
error={name.length > 0 && name.length < 2 ? "Too short" : ""}
/>
<p>Hello, {name}</p>
</div>
);
}
What the component does for you:
- Generates a unique
idwithuseIdand links the label. - Connects the hint and the error through
aria-describedby. - Passes every other prop (
value,onChange,type,placeholder,maxLength…) straight to the<input>with...inputProps, so it behaves like a normal input.
A SelectField
function SelectField({ label, options, ...selectProps }) {
const id = React.useId();
return (
<div style={{ marginBottom: 12 }}>
<label htmlFor={id} style={{ display: "block", fontWeight: 600 }}>{label}</label>
<select id={id} aria-label={label} {...selectProps}>
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
);
}
function App() {
const [level, setLevel] = React.useState("beginner");
return (
<div>
<SelectField
label="Level"
value={level}
onChange={(e) => setLevel(e.target.value)}
options={[{ value: "beginner", label: "Beginner" }, { value: "advanced", label: "Advanced" }]}
/>
<p>Chosen: {level}</p>
</div>
);
}
A tiny useForm hook
The state and handler pattern repeats in every form, so put it in a custom hook (see Custom Hooks):
function useForm(initialValues, validate) {
const [values, setValues] = React.useState(initialValues);
const [submitted, setSubmitted] = React.useState(false);
const errors = validate(values);
const isValid = Object.keys(errors).length === 0;
function handleChange(e) {
const { name, value } = e.target;
setValues((v) => ({ ...v, [name]: value }));
}
function handleSubmit(onValid) {
return (e) => {
e.preventDefault();
setSubmitted(true);
if (isValid) onValid(values);
};
}
function reset() {
setValues(initialValues);
setSubmitted(false);
}
return { values, errors: submitted ? errors : {}, handleChange, handleSubmit, reset };
}
function TextField({ label, error, ...props }) {
const id = React.useId();
return (
<div>
<label htmlFor={id}>{label}</label>{" "}
<input id={id} aria-label={label} aria-invalid={error ? "true" : undefined} {...props} />
{error && <small role="alert" style={{ color: "#b71c1c" }}> {error}</small>}
</div>
);
}
function App() {
const [message, setMessage] = React.useState("");
const form = useForm({ name: "", email: "" }, (v) => {
const e = {};
if (!v.name) e.name = "Required";
if (!v.email.includes("@")) e.email = "Needs @";
return e;
});
return (
<form noValidate onSubmit={form.handleSubmit((v) => setMessage(`Joined: ${v.name} <${v.email}>`))}>
<TextField label="Name" name="name" value={form.values.name} onChange={form.handleChange} error={form.errors.name} />
<TextField label="Email" name="email" value={form.values.email} onChange={form.handleChange} error={form.errors.email} />
<button type="submit">Join</button>
<button type="button" onClick={form.reset}>Reset</button>
<p role="status">{message}</p>
</form>
);
}
The App component now says what the form has, not how to track it.
Should you build or buy?
This small hook handles simple forms well. Large forms with many dependent rules benefit from a library such as React Hook Form or TanStack Form, and a schema validator such as Zod (with a resolver) so the same rules run on the client and on the server. Learn the basics here first; libraries make more sense once you know what they replace.
Design tips for form components
- Pass through the rest of the props so every native attribute works.
- Keep the label inside the component, so nobody forgets it.
- Do not store values inside the field; let the form own them (controlled), unless you have a reason.
- Give every component a clear job. A
TextFieldshould not know about your signup rules.
Key points
- A field component bundles label, input, hint, error and ARIA wiring.
- Forward the rest of the props to the input with
...inputProps. - A
useFormhook removes repeated state and handler code; libraries help with large forms.