How to Bind an Array to Radio Buttons in React
Advertisement
🧩 Scenario
Architecture Walkthrough
Controlled Radio Group
A controlled radio group works the same as any other controlled input: the checked prop comes from state, and onChange updates the state. For a group, checked is a boolean comparison: checked={selectedValue === option.value}. Only one option can match at a time, so only one radio is checked at any render.
The name attribute is what the browser uses to group radio buttons for single-selection behavior. Without a shared name, clicking one radio does not deselect the others because the browser has no way to know they are in the same group. All radios in the same group must have the same name value.
Key and Value Considerations
Each <input type="radio" /> needs a key on its wrapper element (or on the input itself if there is no wrapper). Use the option's stable ID or value, not the array index. The value prop on the radio input is what e.target.value returns when that radio is selected. This is always a string, even if the underlying option ID is a number. When comparing in checked, ensure both sides are the same type.
Accessibility
Wrap the group in a <fieldset> with a <legend> that names the group. This is a semantic HTML requirement for radio button groups, not just a visual nicety: screen readers announce the legend text when the user focuses any radio in the group, giving the context of what they are choosing. Without it, a screen reader user would hear only the individual option labels with no group context.
Key Code Explained
// String values: simple comparison
const PLAN_OPTIONS = [
{ value: 'free', label: 'Free', price: '$0/month' },
{ value: 'pro', label: 'Pro', price: '$29/month' },
{ value: 'enterprise', label: 'Enterprise', price: '$99/month' },
] as const;
type Plan = (typeof PLAN_OPTIONS)[number]['value'];
function PlanRadioGroup({ onChange }: { onChange: (plan: Plan) => void }) {
const [selectedPlan, setSelectedPlan] = useState<Plan>('free');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value as Plan;
setSelectedPlan(value);
onChange(value);
};
return (
<fieldset>
<legend>Select a Plan</legend>
{PLAN_OPTIONS.map((option) => (
<label key={option.value} className="radio-label">
<input
type="radio"
name="plan" // same name groups all radios
value={option.value}
checked={selectedPlan === option.value} // controlled: checked from state
onChange={handleChange}
/>
<span>{option.label}</span>
<span className="price">{option.price}</span>
</label>
))}
</fieldset>
);
}
// Object array with numeric IDs: careful type comparison
interface PaymentMethod {
id: number;
label: string;
icon: string;
}
const PAYMENT_METHODS: PaymentMethod[] = [
{ id: 1, label: 'Credit Card', icon: 'CreditCard' },
{ id: 2, label: 'PayPal', icon: 'Paypal' },
{ id: 3, label: 'Bank Transfer', icon: 'Bank' },
];
function PaymentMethodSelector() {
const [selectedId, setSelectedId] = useState<number | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// e.target.value is always a string — parse to number for comparison
setSelectedId(parseInt(e.target.value, 10));
};
return (
<fieldset>
<legend>Payment Method</legend>
{PAYMENT_METHODS.map((method) => (
<label key={method.id} className="radio-label">
<input
type="radio"
name="payment-method"
value={method.id} // serialized to string in the DOM
// Compare number to number — both sides must be the same type
checked={selectedId === method.id}
onChange={handleChange}
/>
{method.label}
</label>
))}
{selectedId && (
<p>Selected: {PAYMENT_METHODS.find((m) => m.id === selectedId)?.label}</p>
)}
</fieldset>
);
}
// Resettable radio group
function ResettableRating() {
const RATINGS = [1, 2, 3, 4, 5];
const [rating, setRating] = useState<number | null>(null);
return (
<fieldset>
<legend>Rate Your Experience</legend>
{RATINGS.map((r) => (
<label key={r}>
<input
type="radio"
name="rating"
value={r}
checked={rating === r}
onChange={() => setRating(r)}
/>
{r}
</label>
))}
{/* Reset: set state to null and no radio has checked=true */}
<button type="button" onClick={() => setRating(null)}>
Clear
</button>
</fieldset>
);
}
In ResettableRating, resetting to null works because no option will satisfy checked={null === r} (always false), so all radios are unchecked. This is only possible with a controlled group — an uncontrolled radio group cannot be reset programmatically without manipulating the DOM directly.
Tradeoffs
| Approach | Resettable | Value readable in state | Accessible with legend | Use when |
|---|---|---|---|---|
| Controlled (checked from state) | Yes | Yes | Yes | All cases — always prefer controlled |
| Uncontrolled (no checked prop) | No (without DOM access) | No (only on submit) | Yes | Submit-only, no programmatic reset needed |
What Interviewers Actually Check
- Whether you set
checked={selectedValue === option.value}from state - Whether you include the
nameattribute on every radio in the group - Whether you use a stable key (not index)
- Whether you wrap in
fieldsetandlegendfor accessibility - Whether you know
e.target.valueis always a string and handle type comparison correctly
Follow-Up Questions
- How would you build a radio group that allows deselection (clicking the selected radio clears the selection)?
- How would you make individual radio options disabled while keeping others enabled?
- How does ARIA relate to radio button groups, and when would you use
role="radiogroup"instead of<fieldset>? - How would you test a radio group with React Testing Library, specifically verifying that selecting one option deselects the others?
- How does
react-hook-form'sregister()handle radio button groups compared to the manual controlled approach?
Common Candidate Mistakes
- Not setting
checkedfrom state, making the group uncontrolled and unable to reset programmatically - Missing the
nameattribute, allowing multiple radios to be checked simultaneously in the browser - Using array index as the key, which causes state bugs if the options array can be reordered or filtered
- Not wrapping in
<fieldset>and<legend>, which is an accessibility requirement for grouped inputs - Comparing
checked={selectedId === option.id}whenselectedIdis a string frome.target.valueandoption.idis a number, causing the comparison to always be false
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you render a radio group from an array with
checkedfrom state? - Can you use the
nameattribute to enforce single-selection across the group? - Can you use a shared
onChangeto update the selected value for any radio in the group? - Can you wrap the group in
fieldsetandlegendfor accessibility? - Can you handle numeric IDs where
e.target.valueis a string, and fix the type comparison?
Summary
A controlled radio group renders each option as an <input type="radio" /> from an array with map(). The checked prop is a boolean comparison: checked={selectedValue === option.value}. Only one option matches at a time, making the group single-select. The name attribute must be the same for all radios in the group so the browser enforces single-selection.
e.target.value is always a string. When option IDs are numbers, parse the value with parseInt in onChange before storing in state, and compare number to number in checked. Comparing a number to the string from e.target.value without parsing always returns false, making no radio appear selected.
Wrap the entire group in <fieldset> with a <legend> for accessibility. Screen readers announce the legend text when focusing any radio in the group, giving users the context of what they are choosing. A controlled group can be reset by setting state to null or an empty value, at which point no radio satisfies the checked comparison and all appear unselected.
Do I need a separate onChange for each radio button?
No. One onChange handler on each input in the group is enough. They all call the same setter with e.target.value. You can also put the handler on the fieldset or a wrapper div if preferred.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement