Build a To-Do app that doesn't re-render the world on each update
Normalize state (id-keyed map + ordered ids). Memoize the Row component by id. Selector-style subscriptions so toggling one item only re-renders that row. Lazy-load the Edit modal. Confirm-on-duplicate.
A To-Do app is the canonical "easy" question with a hidden senior trap: most candidates write a working version that re-renders all 500 todos every time you toggle one. The interview signal is recognizing the perf pitfalls and structuring state to avoid them.
Naive (works, but slow at scale).
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
return (
<ul>
{todos.map(t => (
<li key={t.id}>
<input type="checkbox" checked={t.done} onChange={() => setTodos(todos.map(x => x.id === t.id ? {...x, done: !x.done} : x))} />
{t.text}
</li>
))}
</ul>
);
}Toggling any item: setTodos returns a new array → React re-renders <App> → maps all todos → every <li> re-renders. Fine at 10 items, miserable at 500.
Better: normalize + extract Row.
type State = { byId: Record<string, Todo>; order: string[] };
const [state, setState] = useState<State>({ byId: {}, order: [] });
function toggleTodo(id: string) {
setState(s => ({ ...s, byId: { ...s.byId, [id]: { ...s.byId[id], done: !s.byId[id].done } } }));
}
function App() {
return (
<ul>
{state.order.map(id => <Row key={id} id={id} />)}
</ul>
);
}
const Row = React.memo(function Row({ id }: { id: string }) {
const todo = useStateValue(s => s.byId[id]); // selector-based
return (
<li>
<input type="checkbox" checked={todo.done} onChange={() => toggleTodo(id)} />
{todo.text}
</li>
);
});Now toggling row 73:
byIdupdates (new object),orderis the same reference.- App's
state.order.mapproduces the same key list — no re-render of App needed if you split state via context+selector or use a store like Zustand. - Row 73's selector returns a new
todoobject → re-renders. - Other rows' selectors return the same reference → memo skips.
In plain React (no Zustand), App still re-renders because state is at App level. Solutions:
- Move state to a Zustand/Redux/Jotai store, subscribe per row.
- Split into many tiny contexts, each row subscribes to its own context value (verbose).
- Pass id only, have Row read its data from a memo-stable map ref.
Zustand is by far the simplest:
const useTodos = create<{ byId: Record<string, Todo>; order: string[]; toggle: (id: string) => void }>((set) => ({
byId: {},
order: [],
toggle: (id) => set(s => ({ byId: { ...s.byId, [id]: { ...s.byId[id], done: !s.byId[id].done } } })),
}));
const Row = React.memo(({ id }: { id: string }) => {
const todo = useTodos(s => s.byId[id]);
const toggle = useTodos(s => s.toggle);
return (...);
});Other features the interviewer will ask for.
- Add — input + button. Trim whitespace, max length, dedupe check (the "task already exists" prompt). Use a confirm dialog when a duplicate text is entered.
- Edit — click row to edit, save on blur or Enter. Lazy-load the Edit modal (
React.lazy) for bundle savings. - Delete — with undo via toast. Don't actually delete for 5s.
- Mark as done / undone — checkbox.
- Filter — All / Active / Completed.
- Empty state — "No todos yet. Add one above."
- Persistence — write to localStorage on each change (debounced).
- Keyboard a11y — Enter to add, Escape to cancel edit, Tab through.
- Focus management — after delete, focus returns to the next row or the input.
Cleanup interviewers love.
- Ref cleanup: input refs and event listeners cleaned in effect return.
- Memo correctness:
React.memoonly helps if props are referentially stable — passing inline arrows kills it. Hoist callbacks viauseCallbackor, better, dispatch from the store directly so Row doesn't take a callback prop.
Anti-patterns to avoid.
- Passing the entire
todosarray to every Row. onChange={() => …}inline — every render is a new function, breaks React.memo.- Index-based key — breaks reorder. (Already covered in keys question.)
- Storing edit state on each todo (
isEditing: true) — UI state should be lifted; otherwise every toggle ships a new "isEditing" comparison.
Tests worth writing.
- Add a todo → it appears.
- Toggle → only the toggled row re-renders (use
rendercount assertions). - Duplicate add → confirm prompt fires.
- Reload → todos persist from localStorage.
Code
Follow-up questions
- •Why does the naive setTodos approach re-render every row?
- •How does normalizing state help here?
- •Why does inline onChange defeat React.memo?
- •How would you implement undo on delete?
Common mistakes
- •Mapping over a flat array and re-rendering all rows on every change.
- •Inline callbacks → React.memo never skips.
- •Index keys → reorder breaks state in inputs.
- •Storing UI state (editing, hover) inside todo objects.
Performance considerations
- •Selector-style subscriptions (Zustand/Redux) so each Row reads only its own slice.
- •Memoize Row by id; pass primitives, not new objects.
- •Virtualize beyond ~500 items.
Edge cases
- •Empty input — disable Add button.
- •Add then immediately delete — focus must move sensibly.
- •Persistence quota exceeded — fall back gracefully.
Real-world examples
- •TodoMVC, Linear's quick-capture, GitHub Projects task lists.