Interview question
Reduce expensive list rerenders deliberately
Uses profiler evidence to isolate expensive derived data and row rendering, then applies memoization without hiding correctness bugs.
TL;DR
Uses profiler evidence to isolate expensive derived data and row rendering, then applies memoization without hiding correctness bugs.
React profiling, derived data, memo, useMemo, useCallback, referential stability, stable keys, mutation bugs, and knowing when virtualization is the larger win.
Practice the problem like a real interview: restate, reason, implement, and test.
A product list filters and sorts 5,000 items. Opening an unrelated details panel reruns the sort and rerenders every row, while typing into search is visibly slow. Use React DevTools Profiler or a local render counter to establish the cause, then change the component so unchanged items and callbacks remain stable without breaking updates when items, query, or selection changes.
With the same items and query, toggling detailsOpen does not run the filter/sort again and does not rerender unchanged rows. Changing query recomputes the visible list. Replacing an item with a new object rerenders that row, selecting an item calls onSelect(id), and the before/after profile shows that the optimization removes meaningful work rather than merely adding memoization.
items array while sorting.useMemo with items and query as complete dependencies.ProductRow; pass the minimum stable props it needs.handleSelect with useCallback because row memoization otherwise sees a new function each render.import { memo, useCallback, useMemo, useState } from "react";
type Product = { id: string; title: string; price: number };
const ProductRow = memo(function ProductRow({
product,
selected,
onSelect,
}: {
product: Product;
selected: boolean;
onSelect: (id: string) => void;
}) {
return (
<li>
<button
type="button"
aria-pressed={selected}
onClick={() => onSelect(product.id)}
>
{product.title} - {product.price}
</button>
</li>
);
});
export function ProductList({
items,
onSelect,
}: {
items: Product[];
onSelect: (id: string) => void;
}) {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const visibleItems = useMemo(() => {
const term = query.trim().toLocaleLowerCase();
return items
.filter((item) => item.title.toLocaleLowerCase().includes(term))
.slice()
.sort((left, right) => left.title.localeCompare(right.title));
}, [items, query]);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
onSelect(id);
}, [onSelect]);
return (
<>
<label>
Search products
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
<button type="button" onClick={() => setDetailsOpen((open) => !open)}>
{detailsOpen ? "Hide details" : "Show details"}
</button>
<ul>
{visibleItems.map((product) => (
<ProductRow
key={product.id}
product={product}
selected={product.id === selectedId}
onSelect={handleSelect}
/>
))}
</ul>
</>
);
}
This depends on callers treating item objects as immutable. If the parent recreates every item object on each render, row memoization cannot help until that upstream identity churn is fixed.
Filtering is O(n) and sorting is O(m log m) for m matched items. useMemo avoids that work only while its dependencies retain identity; it also retains the derived array and performs dependency comparisons. memo adds prop comparisons and helps only when skipped row renders cost more than those comparisons. Rendering 5,000 DOM nodes can still dominate, so virtualization, server filtering, or pagination may be the correct production improvement after measurement.
items in place, so reference-based dependencies do not observe a change.onSelect callback each render.sort() mutates the prop array and creates correctness bugs.memo is added while every prop still receives a new object or function.Next in Frontend Labs: Critical Flow E2E