← Back to question bank
DebuggingSeniorHard#4026 · 35m

Debug transition-driven filtering under interview pressure

A React screen using transition-driven filtering 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 "expensive computation on every keystroke against the urgent value". The cost is felt immediately by the user — typing lags exactly as long as the filter takes. The fix is splitting the read sites: the input value stays urgent (typing always commits), the derived filter reads useDeferredValue (catches up under transition priority). The two states are kept in sync by React; you only choose where each is read.

Locate the boundary by asking "what is the user experiencing as latency?". Input lag → useDeferredValue on the read side. Heavy commit on the next route → useTransition on the write side. The two primitives compose: the input uses neither, the filter uses useDeferredValue, the route navigation uses useTransition. Picking the wrong site (deferring the input itself) breaks the contract.

Adjacent traps: forgetting to memoize the filter (the deferred value still triggers a recomputation if the closure churns), surfacing isStale without an aria-live region (screen readers see the lag without explanation), and assuming useDeferredValue debounces (it does not — it lags one render whenever React is busy, but every value commits eventually). The regression test asserts input.value updates synchronously after fireEvent.change.

Regression Fix: Defer The Filter, Keep The Input Urgent

The fixed FilteredList reads useDeferredValue on the filter side; the input stays controlled by the urgent state.

// THE BUG: an expensive filter re-ran on every keystroke against the
// urgent input value, blocking the input from rendering until the filter
// completed. The fix moves the filter behind useDeferredValue so the
// input is always responsive and the filtered list catches up under
// transition priority.

type Row = { id: string; label: string };

export function FilteredList({ rows }: { rows: Row[] }) {
  const [query, setQuery] = React.useState('');
  // Reading deferredQuery here means the filter never runs against the
  // most recent keystroke — it always uses the value from one stable
  // render ago. The input itself stays on urgent priority.
  const deferredQuery = React.useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const filtered = React.useMemo(
    () =>
      rows.filter((row) => row.label.toLowerCase().includes(deferredQuery.toLowerCase())),
    [rows, deferredQuery]
  );

  return (
    <div>
      <input
        aria-label="Filter"
        value={query}
        onChange={(event)=> setQuery(event.target.value)}
      />
      {isStale && <span aria-live="polite">Updating…</span>}
      <ul style={{ opacity: isStale ? 0.6 : 1 }}>
        {filtered.map((row) => (
          <li key={row.id}>{row.label}</li>
        ))}
      </ul>
    </div>
  );
}

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
Render the broken version with a deliberately expensive filter (e.g., 50k rows, no memo). Type and observe input lag in the dev tools profiler. The fix removes the lag without dropping any keystrokes.
Patch
Move the filter behind useDeferredValue. Add an isStale visual signal so the user sees that the list is catching up. Test input value reflects in the same commit as the keystroke.
Prevent
Add a Lighthouse Total Blocking Time budget for the page. Pair with a Profiler smoke test that asserts the input render path stays under a frame budget when the filter is engaged.
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

const rows = Array.from({ length: 30 }, (_, index) => ({
  id: String(index),
  label: 'item-' + index,
}));

describe('FilteredList regression', () => {
  it('input value updates synchronously even while filter catches up', () => {
    render(<FilteredList rows={rows} />);
    const input = screen.getByLabelText('Filter') as HTMLInputElement;
    fireEvent.change(input, { target: { value: 'item-1' } });
    // Input reflects the new value in the same commit; the filter
    // catches up via useDeferredValue without blocking input render.
    expect(input.value).toBe('item-1');
  });
});

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?