← Back to question bank
DebuggingSeniorHard#4034 · 35m

Debug React key stability under interview pressure

A React screen using React key stability 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 "key={index}". Indices are stable for append-only lists, so the bug stays hidden until reorder, filter, or delete. The first user-visible symptom is uncontrolled input state and focus snapping to the wrong logical row — React kept the DOM node at position N and reused it for whatever row currently sits at position N. The fix is using the row’s natural id (or a synthesized stable id) as the key.

Locate the boundary by asking "would two rows ever swap positions?". For static, append-only data, indices look fine; for editable, sortable, or filterable data, indices are wrong. If your data has no natural id, generate one once at ingestion and treat it as part of the row going forward — never derive ids during render.

Adjacent traps: synthesizing keys from a "good enough" field like name (collides on duplicates), using JSON.stringify(row) (works only until any field changes), and using Math.random() in render (creates a new key every commit, forcing every row to remount). The regression test types into one row, reorders, and asserts the typed value follows the row by id — failing if any of those traps return.

Regression Fix: Use The Row Id, Not The Index

The fixed EditableList keys each row by item.id; uncontrolled input state and focus follow the row through reorders.

// THE BUG: the original list used the array index as the React key. After
// reordering, React kept the input nodes in their original positions and
// reused them for whichever row landed at that index — the user's typing
// snapped to the wrong row, and focus moved with the index, not the
// logical row. The fix uses the row id as the key.

type Item = { id: string; text: string };

export function EditableList({
  items,
  onMoveUp,
}: {
  items: Item[];
  onMoveUp: (id: string) => void;
}) {
  return (
    <ul aria-label="Editable list">
      {items.map((item) => (
        // Stable id — React preserves the input node, defaultValue, and
        // focus across reorders. Index would have been the bug.
        <li key={item.id}>
          <input defaultValue={item.text} aria-label={'row-' + item.id} />
          <button onClick={()=> onMoveUp(item.id)}>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.

Reproduce
Render the broken index-keyed version, type into one row, reorder. Assert the typed value snaps to whatever row is now at that position. The fixed version preserves it on the same logical row.
Patch
Replace key={index} with key={item.id}. Test the reorder path explicitly because static rendering tests pass either way.
Prevent
Add an ESLint rule that forbids array-index keys in editable or sortable lists. Pair with a Storybook story that demonstrates the reorder behavior so designers can confirm the contract by sight.
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

describe('EditableList key regression', () => {
  it('uncontrolled input value follows the row through reorder', () => {
    const initial = [
      { id: 'a', text: 'Alpha' },
      { id: 'b', text: 'Beta' },
    ];
    let current = initial;
    let result: ReturnType<typeof render>;
    const onMoveUp = (id: string) => {
      const index = current.findIndex((row) => row.id === id);
      if (index <= 0) return;
      const next= [...current];
      [next[index - 1], next[index]]= [next[index], next[index - 1]];
      current= next;
      result.rerender(<EditableList items={current} onMoveUp={onMoveUp} />);
    };
    result = render(<EditableList items={current} onMoveUp={onMoveUp} />);

    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; React preserved the input
    // node so the typed value is still on the same logical row.
    const stillB = screen.getByLabelText('row-b') as HTMLInputElement;
    expect(stillB.value).toBe('Beta-edited');
  });
});

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?