Implement React key stability in a product component
Use React key stability to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
React key stability is the question that exposes whether you understand reconciliation. Keys tell React which DOM node, component instance, and effect hooks correspond to which logical row. When the key matches, state is preserved across reorders, inserts, and deletes. When the key changes, React unmounts and remounts — losing focus, scroll, animation state, and any data that lived in component-local hooks.
The rule: keys must be derived from the row’s identity, not its position. An index is stable for a list that only appends, but the moment a row is reordered, removed, or filtered, indices shift and React reuses the old node for new data. The bug is invisible in static lists and obvious in editable ones because cursor position is the canonical "stuck on the wrong row" signal.
Adjacent traps: synthesizing keys from "good enough" fields like `${name}-${type}` (collides on duplicates), using Math.random() per render (forces a remount every time, kills perf and animation), and using JSON.stringify(row) (breaks identity stability when any field changes). If your data has no natural id, generate one once at ingestion and treat it as part of the row, not a render-time artifact.
Reference Implementation: Editable List Keyed By Stable Id
An EditableList where each row uses item.id as the key, so uncontrolled input state and focus follow the row through reorders.
// Stable keys are the contract that lets React preserve component state
// across reorders. The bug surfaces when keys are array indices: reordering,
// inserting, or deleting rows reuses old DOM nodes for new data, which
// keeps cursor position, focus, and uncontrolled state from following
// the row that moved.
type Item = { id: string; text: string };
type EditableListProps = {
items: Item[];
onReorder: (next: Item[]) => void;
};
export function EditableList({ items, onReorder }: EditableListProps) {
return (
<ul aria-label="Editable list">
{items.map((item, index) => (
// KEY MUST be the row id, not the index. The whole point of this
// question is that input cursor position belongs to the row, and
// React preserves it only when the key matches.
<li key={item.id}>
<input defaultValue={item.text} aria-label={'row-' + item.id} />
<button
onClick={()=> {
if (index= 0) return;
const next= [...items];
[next[index - 1], next[index]]= [next[index], next[index - 1]];
onReorder(next);
}}
>
Up
</button>
</li>
))}
</ul>
);
}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('EditableList key stability', () => {
it('preserves uncontrolled input value across row reorder', () => {
const initial = [
{ id: 'a', text: 'Alpha' },
{ id: 'b', text: 'Beta' },
];
let current = initial;
const onReorder = (next: typeof initial) => {
current = next;
rerender(<EditableList items={current} onReorder={onReorder} />);
};
const { rerender } = render(<EditableList items={current} onReorder={onReorder} />);
// User edits row B, then moves it up.
const inputB = screen.getByLabelText('row-b') as HTMLInputElement;
fireEvent.change(inputB, { target: { value: 'Beta-edited' } });
fireEvent.click(screen.getAllByText('Up')[1]);
// After reorder, row B is now first. Because the key is the row id,
// React kept the input alive and the typed value is still there.
const stillB = screen.getByLabelText('row-b') as HTMLInputElement;
expect(stillB.value).toBe('Beta-edited');
});
});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?