React is fast by default for most UI work. The cases where it struggles — janky animations, slow filter interactions, sluggish infinite lists — almost always trace back to one of a few root causes. This post is about those causes and the targeted fixes that actually show up in profiler traces.
Profile First, Optimise Second
React DevTools ships a Profiler panel that records a flame graph of every render. Before reaching for `useMemo` or `memo`, open the Profiler, record an interaction that feels slow, and find the component that's taking the most time. Optimising the wrong thing is how you end up with a codebase full of memoization that does nothing measurable.
Note
The React Compiler (announced at React Conf 2024, available as an opt-in in React 19) automatically applies memoization where it's safe to do so. On projects using React 19+, you may find that manual `useMemo` / `useCallback` is increasingly unnecessary.
The Right Use of memo, useMemo, and useCallback
`React.memo` prevents a component from re-rendering when its parent re-renders, as long as its props haven't changed (shallow comparison). It's worth adding when a component is expensive to render and its props are stable.
const ExpensiveChart = React.memo(function ExpensiveChart({ data }: { data: DataPoint[] }) {
// expensive d3 / canvas rendering
return <canvas ref={canvasRef} />;
});`useCallback` stabilises a function reference across renders. Without it, passing a callback to a memoized child defeats the memoization because a new function instance is created on every render.
// Without useCallback, ExpensiveChart re-renders every time Parent renders
// because onDataPoint is a new function reference each time
const onDataPoint = useCallback((point: DataPoint) => {
setSelected(point);
}, []); // stable — only created once`useMemo` caches the result of an expensive computation. The classic use case is deriving sorted or filtered data from a large array.
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items] // only re-sort when items array reference changes
);useTransition for Non-Blocking State Updates
React 18 introduced `useTransition`, which marks a state update as non-urgent. While the transition is pending, the current UI stays interactive. This is the right fix for filter/search interactions that re-render a large list — the input stays responsive while the list update happens concurrently.
const [isPending, startTransition] = useTransition();
function handleSearch(query: string) {
setQuery(query); // urgent — input updates immediately
startTransition(() => {
setFilteredResults(computeResults(query)); // non-urgent — can yield to input
});
}Virtualise Long Lists
Rendering 10,000 rows in the DOM is always slow, regardless of how well you memoize. Virtualization renders only the rows visible in the viewport. `@tanstack/react-virtual` is the most composable option; `react-window` and AG Grid are better for grid-shaped data.
import { useVirtualizer } from "@tanstack/react-virtual";
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // row height in px
});
return (
<div ref={parentRef} style={{ overflow: "auto", height: 600 }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{ position: "absolute", top: row.start, height: row.size }}
>
{items[row.index].name}
</div>
))}
</div>
</div>
);Code Splitting at Route and Component Level
Bundle size directly affects Time to Interactive. With the Pages Router, per-page code splitting was automatic. In the App Router, server components are never included in the client bundle at all. For heavy client-only components (rich text editors, chart libraries, map embeds), use `dynamic()` with `ssr: false` to defer loading until the component is needed.
import dynamic from "next/dynamic";
const RichEditor = dynamic(() => import("@/components/rich-editor"), {
ssr: false,
loading: () => <EditorSkeleton />,
});Quick Wins Checklist
- Profile with React DevTools before any optimization.
- Lift state as high as needed but no higher — unnecessary shared state causes unnecessary re-renders.
- Keys on list items must be stable and unique, not array index.
- Avoid creating objects/arrays inline in JSX props passed to memoized children.
- Use `useTransition` for search/filter interactions on large datasets.
- Virtualise any list over ~200 items.
- Move one-off expensive computations to the server (RSC or API route).