← Back to question bank
DebuggingSeniorHard#4040 · 35m

Debug state colocation refactor under interview pressure

A React screen using state colocation refactor 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 "lift state up by reflex". The query state lived in the page-level component because someone might need it later, and every keystroke caused the page-level state to change, re-rendering all of the page’s memoized children. The cost is a chart, table, and sidebar re-rendering on every input keystroke for no observable benefit. The fix is moving the state down to the only component that reads or writes it: the picker itself.

Locate the boundary by asking two questions. (1) Which components read this value? If only one subtree, colocate. (2) Which components write this value? If only one component, colocate the writer with the reader. State that is shared by siblings should be lifted to the lowest common ancestor of those siblings — not all the way to the page root, and not as a "we might need it later" precaution.

Adjacent traps: passing state down through multiple layers via props (compose with context or refactor structure), keeping draft input state and committed application state in the same useState (split them so unrelated consumers stop re-rendering on draft changes), and reaching for React.memo to fix bad placement (memo cannot rescue state that is genuinely lifted too high — the deps still change).

Regression Fix: Move Query State Into The Picker

The fixed SearchablePicker owns its query state; the surrounding page no longer re-renders unrelated children on every keystroke.

// THE BUG: the original page hoisted a search input value to the page
// component because "we might need it elsewhere". The expensive Chart
// re-rendered on every keystroke. The fix moves the query state down
// into SearchablePicker; only selection events bubble up to the page.

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

export function SearchablePicker({
  rows,
  onSelect,
}: {
  rows: Row[];
  onSelect: (row: Row) => void;
}) {
  // Query lives here — the only component that reads or writes it.
  const [query, setQuery] = React.useState('');
  const visible = React.useMemo(
    () =>
      query
        ? rows.filter((row) => row.name.toLowerCase().includes(query.toLowerCase()))
        : rows,
    [rows, query]
  );
  return (
    <div>
      <input
        aria-label="Search"
        value={query}
        onChange={(event)=> setQuery(event.target.value)}
      />
      <ul>
        {visible.map((row) => (
          <li key={row.id}>
            <button onClick={()=> onSelect(row)}>{row.name}</button>
          </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 hoisted-query page with a memoized chart probe. Type into the picker. Assert the chart render counter advances on every keystroke. The fix flattens the counter.
Patch
Move the query state into SearchablePicker. Test that the surrounding chart’s render count stays flat across keystrokes. Selection events still bubble up; only the colocation point changed.
Prevent
Add a render-counter perf budget for the page in tests. Pair with a code-review heuristic: "useState that is read by exactly one descendant subtree should live in that subtree."
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

describe('SearchablePicker colocation regression', () => {
  it('typing into the picker does not re-render the surrounding chart', () => {
    let chartRenders = 0;
    const Chart = React.memo(function Chart() {
      chartRenders++;
      return <div data-testid="chart" />;
    });

    function Page() {
      const rows = React.useMemo(
        () => [
          { id: 'a', name: 'Alpha' },
          { id: 'b', name: 'Beta' },
        ],
        []
      );
      const [, setSelected] = React.useState<typeof rows[number] | null>(null);
      return (
        <>
          <Chart />
          <SearchablePicker rows={rows} onSelect={setSelected} />
        </>
      );
    }

    render(<Page />);
    const initialChartRenders = chartRenders;
    fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'al' } });
    fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'alp' } });

    // Chart never re-rendered. The broken hoisted-query version would
    // climb to initialChartRenders + 2.
    expect(chartRenders).toBe(initialChartRenders);
  });
});

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?