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
- Stable keys —
crypto.randomUUID()instead of array index. Reordering and deletes no longer thrash DOM. - Memoized row —
memo(Todo)skips re-render if props are identical. - Stable callbacks —
useCallbackwith no deps; functional updaters mean we never close over staletodos. - Isolated input state —
AddFormowns its owntextstate, 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
React
Medium
9 min