React Performance Optimization Techniques That Actually Work
Practical React performance patterns from production client work — memoization, code splitting, virtualization, and profiling techniques beyond the usual advice.
I’ve inherited React codebases where every component was wrapped in React.memo, the bundle was 1.2 MB, and the app still felt sluggish on a mid-range Android phone. The previous developer followed blog post advice without profiling. Memoization everywhere. Context providers nested six levels deep. A charting library imported in the root layout.
Performance optimization in React isn’t about applying every technique in the docs. It’s about finding the actual bottleneck — render time, bundle size, network waterfall, or main thread blocking — and fixing that specific thing. This article covers the techniques I use on client projects after profiling, not before.
Start With Profiling, Not Guessing
React DevTools Profiler is your first tool. Record an interaction — filtering a table, opening a modal, typing in a search field — and look at:
- Which components re-rendered
- How long each render took
- What triggered the render (props change, state change, context change, parent render)
// Wrap suspicious sections during investigation
import { Profiler } from "react";
function onRenderCallback(
id: string,
phase: "mount" | "update",
actualDuration: number,
) {
if (actualDuration > 16) {
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(1)}ms (${phase})`);
}
}
<Profiler id="ProductGrid" onRender={onRenderCallback}>
<ProductGrid products={products} />
</Profiler>
If a component renders in under 4ms, optimizing it is premature. Focus on components that take 16ms+ (one frame at 60fps) or that render hundreds of times per interaction.
Chrome Performance panel shows the full picture — JavaScript execution, layout, paint, and network — which React DevTools alone won’t give you.
Understanding React’s Render Model
React re-renders a component when:
- Its state changes
- Its parent re-renders (unless memoized with stable props)
- A context it consumes changes
- A hook dependency changes (useMemo, useCallback recalculate)
Re-rendering isn’t expensive by itself. React’s virtual DOM diff is fast. What’s expensive is:
- Running heavy computations during render
- Triggering layout thrashing (reading then writing DOM dimensions)
- Rendering thousands of DOM nodes
- Cascading re-renders through a deep tree
Know which problem you have before picking a solution.
Strategic Memoization With React.memo
React.memo prevents re-renders when props haven’t changed. Use it on components that:
- Render frequently due to parent updates
- Are expensive to render (complex DOM, many children)
- Receive stable props most of the time
import { memo } from "react";
interface ProductCardProps {
id: string;
name: string;
price: number;
onAddToCart: (id: string) => void;
}
const ProductCard = memo(function ProductCard({
id,
name,
price,
onAddToCart,
}: ProductCardProps) {
return (
<article className="product-card">
<h3>{name}</h3>
<p>${price.toFixed(2)}</p>
<button onClick={() => onAddToCart(id)}>Add to cart</button>
</article>
);
});
The trap: memo is useless if props change every render. This is the most common mistake I see:
// Bad — new function reference every render, memo is pointless
<ProductCard onAddToCart={(id) => addToCart(id)} />
// Good — stable reference with useCallback
const handleAddToCart = useCallback((id: string) => {
addToCart(id);
}, [addToCart]);
<ProductCard onAddToCart={handleAddToCart} />
Don’t memo everything. memo has overhead — prop comparison on every parent render. I typically memo 5–15% of components in a codebase, not 95%.
useMemo and useCallback: When They Earn Their Keep
useMemo for Expensive Computations
function ProductList({ products, filters }: Props) {
const filteredProducts = useMemo(() => {
return products
.filter((p) => filters.categories.includes(p.category))
.filter((p) => p.price >= filters.minPrice && p.price <= filters.maxPrice)
.sort((a, b) => a.name.localeCompare(b.name));
}, [products, filters]);
return (
<ul>
{filteredProducts.map((product) => (
<ProductCard key={product.id} {...product} />
))}
</ul>
);
}
Use useMemo when the computation takes measurable time (filtering 1,000+ items, complex transformations) and the dependencies change infrequently. Don’t use it for a + b.
useCallback for Stable Function References
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
trackEvent("search", { query });
}, []);
Needed when passing callbacks to memoized children or as dependencies in other hooks. Not needed for event handlers on non-memoized components.
Code Splitting and Lazy Loading
Bundle size directly affects parse and execution time — which hits INP and time-to-interactive.
import { lazy, Suspense } from "react";
const AnalyticsDashboard = lazy(() => import("./AnalyticsDashboard"));
const ReportExporter = lazy(() => import("./ReportExporter"));
function AdminPage() {
const [view, setView] = useState<"dashboard" | "export">("dashboard");
return (
<Suspense fallback={<DashboardSkeleton />}>
{view === "dashboard" ? <AnalyticsDashboard /> : <ReportExporter />}
</Suspense>
);
}
Route-level splitting is the highest-impact move:
// React Router v6
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{ path: "dashboard", element: <SuspenseWrap><Dashboard /></SuspenseWrap> },
{ path: "settings", element: <SuspenseWrap><Settings /></SuspenseWrap> },
],
},
]);
Analyze your bundle with vite-bundle-visualizer or @next/bundle-analyzer. I routinely find single imports — a date library, an icon pack, a charting library — accounting for 30% of the bundle.
Import Only What You Need
// Bad — imports entire library (300+ KB)
import _ from "lodash";
const sorted = _.sortBy(items, "name");
// Good — tree-shakeable import
import sortBy from "lodash/sortBy";
const sorted = sortBy(items, "name");
// Better — native when possible
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
// Bad — imports all icons
import { FaHome, FaUser, FaCog } from "react-icons/fa";
// Good — individual imports (with most bundlers)
import { FaHome } from "react-icons/fa/FaHome";
Virtualization for Long Lists
Rendering 5,000 table rows or product cards will choke any framework. Virtualization renders only visible items.
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualProductList({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
style={{
position: "absolute",
top: 0,
transform: `translateY(${item.start}px)`,
height: `${item.size}px`,
width: "100%",
}}
>
<ProductRow product={products[item.index]} />
</div>
))}
</div>
</div>
);
}
On a client dashboard with 3,200 transaction rows, virtualization dropped initial render from 2,800ms to 45ms. @tanstack/react-virtual is my go-to — lighter than react-window and better TypeScript support.
Context Optimization
React Context re-renders every consumer when the value changes. Nested providers with frequently changing values cause cascade re-renders.
// Bad — one context, everything re-renders on any change
const AppContext = createContext({ user, theme, cart, notifications });
// Good — split by update frequency
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>("light");
const CartContext = createContext<CartState>(initialCart);
For high-frequency updates (mouse position, scroll, animation frames), avoid Context entirely:
// Use a ref + subscription pattern instead of context for 60fps updates
function useSyncExternalStoreWithSelector<T, S>(
subscribe: (cb: () => void) => () => void,
getSnapshot: () => T,
selector: (state: T) => S,
): S {
// Or use Zustand/Jotai which handle this internally
}
Zustand with selectors is my default for client state that updates often:
import { create } from "zustand";
const useStore = create<AppState>((set) => ({
filters: { category: "all", sort: "name" },
setFilter: (key, value) =>
set((state) => ({
filters: { ...state.filters, [key]: value },
})),
}));
// Only re-renders when filters.category changes
const category = useStore((s) => s.filters.category);
State Colocation
Keep state as close to where it’s used as possible. Lifting state to a parent causes siblings to re-render unnecessarily.
// Bad — modal state in parent re-renders entire page
function ProductPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<ProductDetails />
<RelatedProducts /> {/* re-renders when modal toggles */}
<Reviews /> {/* re-renders when modal toggles */}
<Modal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
</>
);
}
// Good — modal manages its own state
function ProductPage() {
return (
<>
<ProductDetails />
<RelatedProducts />
<Reviews />
<ProductModal triggerLabel="View specs" />
</>
);
}
Debouncing and Throttling User Input
Search-as-you-type without debouncing fires a re-render and API call on every keystroke:
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [value, setValue] = useState("");
const debouncedSearch = useMemo(
() => debounce((query: string) => onSearch(query), 300),
[onSearch],
);
useEffect(() => {
debouncedSearch(value);
return () => debouncedSearch.cancel();
}, [value, debouncedSearch]);
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search products..."
/>
);
}
For scroll handlers, use requestAnimationFrame throttling or CSS position: sticky instead of JavaScript scroll listeners where possible.
Image and Asset Optimization in React
function ProductImage({ src, alt }: { src: string; alt: string }) {
return (
<img
src={src}
alt={alt}
width={400}
height={400}
loading="lazy"
decoding="async"
style={{ aspectRatio: "1 / 1" }}
/>
);
}
In Next.js, always use next/image. In Vite/React SPAs, consider @unpic/react for responsive images or serve pre-optimized assets from your CDN.
Concurrent Features in React 18+
React 18’s concurrent rendering helps perceived performance:
import { useDeferredValue, useTransition } from "react";
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(
() => filterProducts(deferredQuery),
[deferredQuery],
);
return (
<ul style={{ opacity: isStale ? 0.6 : 1 }}>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
);
}
function FilterPanel() {
const [filters, setFilters] = useState(initialFilters);
const [isPending, startTransition] = useTransition();
const handleChange = (newFilters: Filters) => {
startTransition(() => {
setFilters(newFilters);
});
};
return (
<>
<FilterControls onChange={handleChange} />
{isPending && <Spinner />}
<ProductGrid filters={filters} />
</>
);
}
useDeferredValue and useTransition mark updates as non-urgent, keeping the UI responsive during expensive re-renders.
Real Client Scenario: Dashboard Optimization
A US SaaS client had a React dashboard that took 6 seconds to become interactive. Profiling revealed:
- 2.1 MB JavaScript bundle — fixed with route-level code splitting (-1.4 MB)
- Recharts imported globally — moved to lazy-loaded route (-380 KB)
- Entire table re-rendered on sort — added
memoto row components + stable callbacks (-800ms render) - 5,000 rows rendered at once — added virtualization (-2,600ms initial render)
- Context with 12 values — split into 3 contexts by update frequency (-200ms per interaction)
Final result: 1.2s to interactive, INP under 120ms. No rewrite — surgical fixes over two weeks.
Common Mistakes
Premature memoization. Wrapping every component in memo adds comparison overhead without benefit.
Ignoring bundle size. A perfectly memoized app with a 3 MB bundle still feels slow.
Profiling in dev mode. React dev mode is slower. Profile production builds.
Optimizing render time when network is the bottleneck. Check Network tab before touching components.
Using useEffect for derived state. Causes extra render cycles. Compute during render or use useMemo.
Conclusion
React performance optimization is a diagnostic discipline. Profile first, identify whether the bottleneck is render time, bundle size, or network, then apply the right technique. Memoization, code splitting, virtualization, and state architecture aren’t interchangeable tricks — they’re targeted tools for specific problems.
The fastest React app I maintain isn’t the one with the most optimizations. It’s the one where every optimization maps to a measured improvement.
Key Takeaways
- Profile with React DevTools and Chrome Performance before optimizing — guesswork wastes time
- Use
React.memoonly on expensive components with stable props, not on every component useCallbackanduseMemoexist to prevent unnecessary work and stabilize references, not as default wrappers- Route-level code splitting is the highest-impact bundle size optimization for React apps
- Virtualize lists with 100+ items using
@tanstack/react-virtual - Split React Context by update frequency; use Zustand for high-frequency state
- Colocate state near where it’s used to prevent cascade re-renders
- React 18’s
useTransitionanduseDeferredValuekeep the UI responsive during expensive updates