Implement component API design in a product component
Use component API design to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
Component API design is the question that asks whether you can produce an interface someone else can use without reading the source. The rule is short: name the contract that makes the call-site obvious. A controlled component takes value + onValueChange; an uncontrolled component takes defaultValue. Mixing the two (value + defaultValue, or value + onChange-as-event) creates the "did you forget to lift state?" class of bugs.
Three principles. Stable typed values: pass the typed value, not labels or indices, so callers do not parse strings. Verb-named callbacks: onValueChange not onChange (event), onSelect not onClick (selection semantics, not pointer mechanics). And no overloaded props: do not let label double as id, do not let placeholder double as default — separate semantic fields.
Adjacent traps: returning the change event instead of the value (forces every caller to write event.target.value), accepting children as both options and slots (ambiguous rendering rules), and exposing internal refs via fancy renderInput patterns when forwardRef is enough. The reference Select is generic over the value type so misuse fails at compile time, not in production.
Reference Implementation: Typed Controlled Select
A generic Select<V> that takes typed value, options, and onValueChange — no event leakage, no label-as-id ambiguity.
// The contract: stable value, stable change handler, no implicit "label as
// id" coupling. The Select takes a typed value, an array of options, and
// an onValueChange. Callers never have to remember "is this the option
// label or the id?" — the answer is always "value".
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>
);
}
// Demonstrate how callers compose the controlled API. The parent owns
// state — Select never holds its own value because that would split truth
// between component-local state and parent props.
export function PriorityForm() {
const [priority, setPriority] = React.useState<'low' | 'medium' | 'high'>('medium');
return (
<Select
ariaLabel="Priority"
value={priority}
onValueChange={setPriority}
options={[
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
]}
/>
);
}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 component contract', () => {
it('renders the value, not the label, as the selected key', () => {
const onValueChange = vi.fn();
render(
<Select
ariaLabel="Status"
value="open"
onValueChange={onValueChange}
options={[
{ value: 'open', label: 'In progress' },
{ value: 'closed', label: 'Resolved' },
]}
/>
);
const select = screen.getByLabelText('Status') as HTMLSelectElement;
expect(select.value).toBe('open');
});
it('forwards typed value through onValueChange', () => {
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' } });
// Argument is the value, not an event. The contract is callsite-friendly.
expect(onValueChange).toHaveBeenCalledWith('closed');
});
it('respects per-option disabled', () => {
render(
<Select
ariaLabel="Plan"
value="free"
onValueChange={()=> {}}
options={[
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro', disabled: true },
]}
/>
);
const proOption = screen.getByText('Pro') as HTMLOptionElement;
expect(proOption.disabled).toBe(true);
});
});Interviewer Signal
Shows whether you can explain React behavior while building maintainable product UI.
Constraints
- Name what is render-derived and what is stored state.
- Keep side effects owned by events or effects deliberately.
- Provide a testable boundary for business logic.
Model Answer Shape
- Start from the user workflow and state ownership.
- Move pure decisions out of JSX when the branch logic grows.
- Use React primitives to express ownership, not to hide unclear state.
Tradeoffs
- Colocating state improves clarity until sibling coordination becomes the real problem.
- Memoization helps only after render cost or identity churn is measured.
Edge Cases
- Strict Mode re-running development effects.
- Stale closures after async work resolves.
- Unmounts and route changes during in-flight operations.
Testing And Proof
- Reducer or pure function test for core state transitions.
- Interaction test for the user workflow.
- Regression case for stale or repeated async behavior.
Follow-Ups
- How would this change with server rendering?
- Where would you place this state in a larger app?