Back to React
React
medium
mid

How would you build a To Do list in React and optimize it to avoid unnecessary re renders?

Classic interview build. Keys move from index → stable id. Split state by concern (input vs list). Memoize the row component with React.memo and pass stable callbacks via useCallback. Use functional updaters in setState to avoid stale closures. For very long lists, virtualize with react-window. Avoid putting all todos in a single object that gets re-cloned per keystroke.

6 min read·~25 min to think through

Bread-and-butter live coding. Optimizations matter — keys, memoization, callback stability.

Naive version

tsx
function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [text, setText] = useState('');

  const add = () => {
    if (!text.trim()) return;
    setTodos([...todos, { id: crypto.randomUUID(), text, done: false }]);
    setText('');
  };

  const toggle = (id: string) =>
    setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t));

  const remove = (id: string) => setTodos(todos.filter(t => t.id !== id));

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={add}>Add</button>
      <ul>
        {todos.map(t => (
          <li key={t.id}>
            <input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />
            <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>{t.text}</span>
            <button onClick={() => remove(t.id)}>x</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Works, but every keystroke in the input re-renders every TodoItem.

Optimized version

tsx
const Todo = memo(function Todo({ todo, onToggle, onRemove }: {
  todo: Todo;
  onToggle: (id: string) => void;
  onRemove: (id: string) => void;
}) {
  return (
    <li>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
      />
      <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>{todo.text}</span>
      <button onClick={() => onRemove(todo.id)}>x</button>
    </li>
  );
});

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);

  const toggle = useCallback((id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }, []);

  const remove = useCallback((id: string) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  }, []);

  const add = useCallback((text: string) => {
    setTodos(prev => [...prev, { id: crypto.randomUUID(), text, done: false }]);
  }, []);

  return (
    <div>
      <AddForm onAdd={add} />
      <ul>
        {todos.map(t => (
          <Todo key={t.id} todo={t} onToggle={toggle} onRemove={remove} />
        ))}
      </ul>
    </div>
  );
}

function AddForm({ onAdd }: { onAdd: (text: string) => void }) {
  const [text, setText] = useState('');
  return (
    <form onSubmit={e => { e.preventDefault(); if (text.trim()) { onAdd(text); setText(''); }}}>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button>Add</button>
    </form>
  );
}

What changed and why

  1. Stable keyscrypto.randomUUID() instead of array index. Reordering and deletes no longer thrash DOM.
  2. Memoized rowmemo(Todo) skips re-render if props are identical.
  3. Stable callbacksuseCallback with no deps; functional updaters mean we never close over stale todos.
  4. Isolated input stateAddForm owns its own text state, so typing doesn't re-render the parent list.

Profile to verify

React DevTools → Profiler → record while typing. Before: every row renders per keystroke. After: only AddForm renders.

For very long lists

10,000+ todos: even O(n) re-renders cost too much. Use react-window:

tsx
import { FixedSizeList } from 'react-window';

<FixedSizeList height={400} itemCount={todos.length} itemSize={32} width="100%">
  {({ index, style }) => (
    <div style={style}>
      <Todo todo={todos[index]} onToggle={toggle} onRemove={remove} />
    </div>
  )}
</FixedSizeList>

Other improvements

  • Persist to localStorage via useEffect.
  • Filter (all/active/completed) — derive with useMemo, don't store filtered list.
  • Drag-and-drop reorder with @dnd-kit.
  • Optimistic mutation if backed by an API.

Follow-up questions

  • Why is React.memo not enough without stable callbacks?
  • How would you persist todos across reloads?
  • When would you reach for react-window?

Common mistakes

  • Using array index as key — reordering or insertions cause input state to leak between rows.
  • Mutating the array (`todos.push`) — React doesn't see the change.
  • Adding useCallback without memoing the consumer — pure overhead.

Performance considerations

  • For typical lists (under 200 items) memoization alone is plenty. Beyond 1000, virtualize. For >10k items consider chunked rendering or moving filter/search work into a worker. Profile before optimizing.

Edge cases

  • Submitting a form: prevent default, trim, ignore empty.
  • Concurrent edits: if two tabs sync via localStorage, last-writer-wins.
  • StrictMode dev double-invokes — uuid generation must still be safe (it is).

Real-world examples

  • TodoMVC. Linear, Notion, Things — all use virtualization once lists get large. The TanStack Virtual library is the modern successor to react-window.

Senior engineer discussion

Senior framing: optimization is layered. Keys → component split → memoization → virtualization. Each step only happens when the previous one isn't enough. Show the interviewer you can reason about WHEN each technique is appropriate, not just WHAT it does.

Related questions