Debug component API design under interview pressure
A React screen using component API design passes simple tests but breaks during repeated interaction. Find the likely root cause, patch it, and describe the longer-term design improvement.
Answer Strategy
The broken pattern in this question is "API that leaks DOM details into the caller". Returning a synthetic event from onChange forces every caller to write event.target.value and re-cast the result; using label as a fallback identity merges two semantically distinct options. The fix is a typed contract: value is the identity, onValueChange returns the typed value, label is presentation-only. Generic V<extends string> makes misuse a compile error.
Locate the boundary by asking "what does the caller actually want to know?". For Select, the answer is "which option did the user pick" — the value, not the event object. The API should return that directly. Anything else (event, target, raw string) is leakage; anything that conflates two semantic fields (label as value) is a footgun.
Adjacent traps: accepting children instead of options (forces callers to render <option>s and exposes raw DOM), making placeholder also represent "no selection" (use null instead so the caller decides the wording), and over-typing with branded strings that callers cannot construct (the type system is for catching bugs, not gatekeeping). The reference Select is generic over V so a value not in the options union fails compilation.
Regression Fix: Typed Value + Verb-Named Callback
The fixed Select<V> exposes value + onValueChange and treats label as presentation only; option.value is the identity field.
// THE BUG: the original Select returned a synthetic event from onChange,
// forcing every caller to write event.target.value. It also accepted
// label as the value when the value prop was omitted, so two options
// with the same label silently shared selection. The fix exposes a
// typed value + onValueChange API and removes the label-as-value
// fallback.
type Option<V extends string> = { value: V; label: string; disabled?: boolean };
type SelectProps<V extends string> = {
value: V;
onValueChange: (next: V) => void;
options: Option<V>[];
ariaLabel: string;
};
export function Select<V extends string>({
value,
onValueChange,
options,
ariaLabel,
}: SelectProps<V>) {
return (
<select
aria-label={ariaLabel}
value={value}
onChange={(event)=> onValueChange(event.target.value as V)}
>
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
}Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
describe('Select API regression', () => {
it('exposes the typed value, not the synthetic event', () => {
const onValueChange = vi.fn();
render(
<Select
ariaLabel="Status"
value="open"
onValueChange={onValueChange}
options={[
{ value: 'open', label: 'Open' },
{ value: 'closed', label: 'Closed' },
]}
/>
);
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'closed' } });
// Caller receives the typed value directly. The broken API forced
// every site to dig into event.target.value and re-cast the type.
expect(onValueChange).toHaveBeenCalledWith('closed');
});
it('uses option.value as the source of truth, not option.label', () => {
const onValueChange = vi.fn();
render(
<Select
ariaLabel="Plan"
value="enterprise-2024"
onValueChange={onValueChange}
options={[
{ value: 'enterprise-2024', label: 'Enterprise' },
{ value: 'enterprise-2025', label: 'Enterprise' },
]}
/>
);
// Two options with the same label coexist because the value is the
// identity field. The broken API picked by label and silently routed
// both options to the same selection.
fireEvent.change(screen.getByLabelText('Plan'), { target: { value: 'enterprise-2025' } });
expect(onValueChange).toHaveBeenCalledWith('enterprise-2025');
});
});Interviewer Signal
Tests whether you debug from ownership and lifecycle instead of random dependency-array edits.
Constraints
- State a hypothesis before changing code.
- Name what evidence would confirm the bug.
- Avoid broad rewrites unless the current API cannot express the behavior.
Model Answer Shape
- Reproduce the failing sequence first.
- Inspect ownership boundaries: local state, props, effects, subscriptions, and server data.
- Patch the minimal broken boundary and add a regression test.
Tradeoffs
- A minimal patch reduces risk, but repeated lifecycle bugs often justify a small reducer or custom hook.
- Adding dependencies can silence lint warnings while still preserving the wrong ownership model.
Edge Cases
- Double clicks and repeated submissions.
- Slow network responses arriving out of order.
- Component remount with stale persisted state.
Testing And Proof
- Failing interaction sequence.
- Out-of-order async response.
- Unmount cleanup.
Follow-Ups
- What would the code review comment say?
- What metric or log would show this in production?