Rendering lists of data is one of the most common tasks in React. Understanding how to do it correctly and efficiently is crucial for building performant applications.Real-world analogy for keys: Imagine a teacher taking attendance in a classroom. If students do not have names (no keys), and one student leaves, the teacher has to re-check every seat from the beginning. But if every student has a name badge (a key), the teacher instantly knows which student left and which ones just shifted seats. That is exactly what React’s key prop does — it gives each list item an identity so React can efficiently track additions, removals, and reorderings without rebuilding the entire list.
Keys are special string attributes you need to include when creating lists of elements. They help React identify which items have changed, been added, or been removed.
// ✅ BEST: Database IDs{users.map(user => <UserCard key={user.id} user={user} />)}// ✅ GOOD: Unique identifiers in data{products.map(product => <ProductCard key={product.sku} product={product} />)}// ✅ OK: Generated unique IDs (when adding items)const [todos, setTodos] = useState([]);const addTodo = (text) => { setTodos([...todos, { id: crypto.randomUUID(), text }]);};// ⚠️ CAUTION: Index (only for static lists that never reorder){staticItems.map((item, index) => <li key={index}>{item}</li>)}// ❌ BAD: Random values{items.map(item => <li key={Math.random()}>{item}</li>)}
Never use Math.random() or Date.now() as keys! These change on every render, causing React to recreate all elements, destroying component state and causing performance issues.
Using array index as key causes one of the most frustrating bugs in React: state gets associated with the wrong item when the list order changes. This happens because React uses the key to match old and new elements. If the key stays the same but the item at that position changes, React reuses the old component instance (including its internal state) for a completely different item.
// ❌ PROBLEMATIC - State gets mixed up when reorderingfunction TodoList() { const [todos, setTodos] = useState([ { id: 1, text: 'Learn React' }, { id: 2, text: 'Build project' } ]); const moveToTop = (id) => { const todo = todos.find(t => t.id === id); setTodos([todo, ...todos.filter(t => t.id !== id)]); }; return ( <ul> {todos.map((todo, index) => ( // Using index as key - BAD when reordering! <TodoItem key={index} todo={todo} /> ))} </ul> );}// ✅ CORRECT - Use unique ID{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
Keys only need to be unique among siblings. In the example above, product.id can be the same number as category.id because they’re in different levels.
You have a list of 10,000 items that users can reorder via drag-and-drop. Using array index as the key, users report that input fields inside list items lose their text on reorder. Diagnose the issue and fix it.
Strong Answer:
This is a classic key identity bug. When you use array index as the key, React maps keys to component instances. Before the drag, key=0 corresponds to item A, key=1 to item B. After dragging item B to position 0, key=0 now corresponds to item B, but React still has the component instance from key=0 — which holds item A’s internal state (the text in the input field). React sees the same key at the same position and reuses the component, updating its props but keeping its internal state. The result: item B’s row shows item A’s input text.The fix is using a stable, unique identifier as the key — the item’s database ID, UUID, or any value that is tied to the item’s identity rather than its position. With key={item.id}, when item B moves to position 0, React sees key=“b-uuid” at position 0 (which was previously at position 1) and moves the existing component instance with its correct state.For the performance angle with 10,000 items: proper keys combined with React.memo on the list item component means React only updates the items that actually moved — typically just 2 items in a single drag operation. With index keys, React would diff and potentially update all 10,000 items because every key-to-data mapping changed.In production, I would also add list virtualization (react-window or react-virtuoso) for 10,000 items. You only render the visible 20-50 items, so even if reconciliation is triggered, only a handful of components participate.Follow-up: Can you ever safely use index as a key? What are the exact conditions?Three conditions must all be true: the list is static (items are never added, removed, or reordered), the items have no internal state (no inputs, no expanded/collapsed states), and the items are not referenced by other components. A list of static labels that never changes meets all three. A navigation menu with fixed items meets all three.The moment any condition is violated, index keys become dangerous. In practice, I default to unique IDs because the cost is negligible and the safety benefit is significant. The only exception I make is for truly ephemeral rendering where the list is generated fresh each time and has no interactivity — like rendering pagination dots or star ratings.
How does React's reconciliation algorithm handle list reordering differently from list insertion? What is the time complexity?
Strong Answer:
React’s reconciliation algorithm for lists is essentially a keyed diffing algorithm. It builds a map of old keys to old fiber nodes, then iterates through the new list of keys. For each new key, it either finds a match in the old map (reuse the fiber, possibly move it in the DOM) or creates a new fiber.For insertion at the end, React iterates through the list, finds all existing keys match, then sees a new key at the end. It creates one new DOM node. This is O(n) for the comparison plus O(1) for the insertion — effectively O(n) total.For reordering (say, moving the last item to the first position), React identifies that all existing keys are still present but in different positions. It reuses all fiber nodes but must determine which DOM nodes to move. React uses a “last placed index” heuristic: it scans the new list left to right, tracking the highest index from the old list it has seen so far. If the current item’s old index is less than the last placed index, it needs to move. This is O(n) with minimal DOM operations — typically moving only the items that shifted.For insertion at the beginning (the worst case for index keys), with proper keys React creates one new node and moves nothing — the existing nodes stay in place and the new node is inserted at position 0. With index keys, React sees that the data at every index changed and updates every node’s content — O(n) DOM writes instead of O(1).The key insight: React’s diffing is always O(n) for the comparison pass, but the number of actual DOM mutations depends heavily on the quality of keys. Good keys minimize mutations; bad keys maximize them.Follow-up: How would you benchmark the actual rendering performance difference between index keys and ID keys on a list of 5,000 items with frequent reordering?I would use the React DevTools Profiler in combination with the browser Performance panel. In the Profiler, record a drag-and-drop operation with index keys, noting the number of components that re-rendered and the total render time. Then switch to ID keys and record the same operation. The Profiler’s flame graph shows exactly which components rendered and how long each took.For DOM-level measurement, the Performance panel’s “Rendering” tab lets you enable Paint Flashing, which highlights every DOM region that was repainted. With index keys you would see the entire list flash; with ID keys, only the moved items flash.For automated benchmarking, I would use performance.mark() and performance.measure() around the state update, or React’s Profiler component with an onRender callback that logs commit times. Run each scenario 50 times and compare the median commit duration. On a 5,000-item list with frequent reordering, I would expect ID keys to be 10-50x faster in DOM mutations, though the JavaScript diffing cost remains similar.
When rendering a filtered or sorted list, should you filter/sort in the render body, in useMemo, or in a useEffect? Explain the tradeoffs.
Strong Answer:
The correct default is computing it in the render body — just a variable assignment: const filtered = items.filter(i => i.active). This is simple, always up to date, and for most list sizes (under a few hundred items), the performance cost is negligible. React will run this on every render, but filtering an array of 100 objects takes microseconds.The useMemo approach — const filtered = useMemo(() => items.filter(i => i.active), [items]) — is appropriate when the computation is genuinely expensive. Sorting 10,000 objects with a complex comparator, chaining multiple filters, or computing derived aggregations (group by, reduce) are good candidates. useMemo caches the result and only recomputes when items changes, skipping the work on unrelated re-renders (like typing in a search field that does not affect the items array).The useEffect approach — storing filtered results in separate state — is almost always wrong. It introduces an unnecessary state variable, causes an extra render (the first render has stale filtered data, the effect runs after paint and triggers a second render with correct data), and creates a synchronization bug if you forget a dependency. The React docs explicitly call this an anti-pattern: “If you can calculate something from the existing props or state, don’t put it in state. Instead, calculate it during rendering.”The one legitimate use of useEffect for derived data is when the derivation has an asynchronous step — like filtering requires a server call. But that is data fetching, not derivation.Follow-up: If you use useMemo for a filtered list, what happens if the items array is the same data but a new reference every render? How do you fix this?If the parent recreates the array on every render (e.g., items={data.map(x => ({ ...x }))}) the useMemo re-runs every time because [items] has a new reference. The memoization is worthless.The fix depends on where the array comes from. If it comes from a parent via props, the parent should memoize it: const items = useMemo(() => data.map(transform), [data]). If it comes from a Redux selector, useSelector already uses reference equality by default, so the selector should return the same reference if the underlying data has not changed — use createSelector from Reselect for derived data. If it comes from a useState, ensure you only call setState with a new array when the data actually changes, not on every render.The general principle: memoization only helps when the inputs are stable. If the inputs are new on every render, memoization adds overhead (comparing dependencies) without saving any work.