← Back to question bank
DebuggingSeniorHard#4038 · 35m

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.

Reproduce
Inspect a caller of the broken API. Count the number of event.target.value boilerplate lines. Add a generic-typed test that catches the value/label conflation; the test fails on the broken API.
Patch
Replace the event-shaped onChange with a typed onValueChange. Make value generic over the options union. Test that callers pass typed values and receive typed values, with no DOM event leakage.
Prevent
Add a code-review heuristic: "if the caller has to dig into event.target, the API is wrong." Pair with a TypeScript test (tsd or expectError) for the misuse cases.
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?