Implement state colocation refactor in a product component
Use state colocation refactor to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
State colocation is the refactor that asks "who actually reads this state, and who actually writes it?" If the answer is one subtree, the state belongs inside that subtree. Lifting state up the tree by reflex is the canonical performance regression: a search input owned by the page-level component re-renders every chart, table, and sidebar on every keystroke. Push the state down to its smallest reader and the rest of the tree goes quiet.
Two questions to apply during refactor. (1) Who reads this value? If only one component subtree, colocate. (2) Who writes this value? If only one component, colocate the writer with the reader. State that is read or written by siblings should be lifted to the lowest common ancestor of those siblings, not all the way to the page root. The Dashboard above lifts only the selection because the Chart and Picker are both consumers.
Adjacent traps: passing state down through three levels of "spread props" (compose with context or by lifting just one level), keeping draft text and committed value in the same useState (split them so unrelated consumers stop re-rendering on each keystroke), and treating React.memo as a fix for unfortunate placement (memoization helps when the Page is well-decomposed; it cannot rescue badly-placed state). The refactor test asserts a render counter rather than UI output — that is the contract that proves the colocation actually paid off.
Reference Implementation: Colocated Search Inside Picker
A SearchablePicker that owns its own query state so typing does not re-render the surrounding Chart; selection is the only event that bubbles up.
// State colocation: keep state next to the only component that reads or
// writes it. Lifting state too high causes unrelated children to re-render
// on every change. The refactor moves a search input down into a
// SearchablePicker so that selecting a row no longer re-renders the
// entire dashboard.
type Row = { id: string; name: string };
type SearchablePickerProps = {
rows: Row[];
onSelect: (row: Row) => void;
};
// AFTER THE REFACTOR: query lives inside SearchablePicker. The dashboard
// passes only the rows array; selection is the only event that bubbles up.
export function SearchablePicker({ rows, onSelect }: SearchablePickerProps) {
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 rows"
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>
);
}
type DashboardProps = { rows: Row[] };
export function Dashboard({ rows }: DashboardProps) {
const [selected, setSelected] = React.useState<Row | null>(null);
return (
<section>
{/* Heavy chart only re-renders when selection changes, NOT when
the user types in the picker. That is the colocation win. */}
<Chart selected={selected} />
<SearchablePicker rows={rows} onSelect={setSelected} />
</section>
);
}
const Chart = React.memo(function Chart({ selected }: { selected: Row | null }) {
return <div role="figure">Selected: {selected ? selected.name : 'none'}</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.
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
describe('SearchablePicker colocation', () => {
it('typing into the picker does not re-render the chart', () => {
let chartRenders = 0;
const ChartProbe = React.memo(function ChartProbe() {
chartRenders++;
return <div data-testid="chart" />;
});
function Page() {
const rows = React.useMemo(
() => [
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Bravo' },
],
[]
);
const [, setSelected] = React.useState<typeof rows[number] | null>(null);
return (
<>
<ChartProbe />
<SearchablePicker rows={rows} onSelect={setSelected} />
</>
);
}
render(<Page />);
const initial = chartRenders;
fireEvent.change(screen.getByLabelText('Search rows'), { target: { value: 'al' } });
fireEvent.change(screen.getByLabelText('Search rows'), { target: { value: 'alp' } });
// Chart never re-rendered because query state is colocated.
expect(chartRenders).toBe(initial);
});
it('selection bubbles up and re-renders the chart exactly once', () => {
let chartRenders = 0;
const ChartProbe = React.memo(function ChartProbe({
selected,
}: {
selected: { id: string } | null;
}) {
chartRenders++;
return <div data-testid="chart">{selected?.id ?? 'none'}</div>;
});
function Page() {
const rows = React.useMemo(() => [{ id: 'a', name: 'Alpha' }], []);
const [selected, setSelected] = React.useState<typeof rows[number] | null>(null);
return (
<>
<ChartProbe selected={selected} />
<SearchablePicker rows={rows} onSelect={setSelected} />
</>
);
}
render(<Page />);
const initial = chartRenders;
fireEvent.click(screen.getByText('Alpha'));
expect(chartRenders).toBe(initial + 1);
});
});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?