The Performance Mental Model
React's rendering model is straightforward: when state or props change, React re-renders the component and all its children. This is simple and correct by default. Performance optimization is the art of telling React: "this component's output hasn't changed — skip it."
The golden rule: measure before optimizing. Premature optimization of the wrong bottleneck wastes time.
The React DevTools Profiler
Before writing a single line of optimization code, profile your app:
- Install React DevTools (Chrome/Firefox extension)
- Open DevTools → Profiler tab
- Click Record → interact with your slow UI → Stop recording
- Look for components with a flame chart bar wider than ~16ms (60fps budget)
The profiler shows:
- Which components rendered and why
- How long each render took
- Which renders were caused by props changes vs state vs context
React.memo — Skip Unnecessary Re-renders
// Without memo — re-renders every time parent re-renders
function BlogCard({ post }: { post: Post }) {
return <div>{post.title}</div>;
}
// With memo — only re-renders when post prop changes (shallow comparison)
const BlogCard = React.memo(function BlogCard({ post }: { post: Post }) {
return <div>{post.title}</div>;
});
// Custom comparison — deep equality for specific fields
const BlogCard = React.memo(
function BlogCard({ post }: { post: Post }) {
return <div>{post.title}</div>;
},
(prevProps, nextProps) => {
return (
prevProps.post.id === nextProps.post.id &&
prevProps.post.title === nextProps.post.title &&
prevProps.post.views === nextProps.post.views
);
}
);When to use React.memo:
- Components that render often but rarely receive new props
- Components that are expensive to render (complex JSX trees)
- Child components of frequently updating parents
When NOT to use it:
- Simple components that render in <1ms
- Components that almost always receive different props
useMemo & useCallback
function PostList({ posts, filters }: { posts: Post[]; filters: Filter }) {
// Without useMemo — filteredPosts recalculates on EVERY render
// If posts = 10,000 items, this is expensive
const filteredPosts = posts.filter(
(p) => p.category === filters.category && p.status === "published"
);
// With useMemo — only recalculates when posts or filters.category changes
const filteredPosts = useMemo(
() =>
posts.filter(
(p) => p.category === filters.category && p.status === "published"
),
[posts, filters.category] // Dependencies
);
// Without useCallback — new function reference on every render
// Causes BlogCard to re-render even if the card's post didn't change
const handleLike = (postId: string) => {
likePost(postId);
};
// With useCallback — stable function reference
const handleLike = useCallback(
(postId: string) => {
likePost(postId);
},
[likePost] // Only changes when likePost changes
);
return (
<div>
{filteredPosts.map((post) => (
<BlogCard key={post.id} post={post} onLike={handleLike} />
))}
</div>
);
}Code Splitting with React.lazy & Suspense
Don't load the entire app upfront. Split code at route boundaries:
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
// These components are NOT in the initial bundle
// They load on-demand when the route is visited
const BlogPage = lazy(() => import("./pages/BlogPage"));
const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const BlogPost = lazy(() => import("./pages/BlogPost"));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/:slug" element={<BlogPost />} />
<Route path="/admin/*" element={<AdminDashboard />} />
</Routes>
</Suspense>
);
}Result: initial bundle drops from ~800KB to ~150KB. Admin dashboard code only loads when someone visits /admin.
Component-Level Splitting
// Heavy chart library — only load when chart is actually visible
const HeavyChart = lazy(() =>
import("recharts").then((m) => ({
default: m.ComposedChart,
}))
);
function AnalyticsDashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Load Chart</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
)}
</div>
);
}Virtual Scrolling — Render Only What's Visible
Rendering 10,000 DOM nodes kills performance. Virtual lists render only the ~20 visible items, swapping them out as the user scrolls.
import { FixedSizeList, VariableSizeList } from "react-window";
// For items with uniform height
function PostList({ posts }: { posts: Post[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
<BlogCard post={posts[index]} />
</div>
);
return (
<FixedSizeList
height={800} // Visible area height
width="100%"
itemCount={posts.length}
itemSize={120} // Each row height in px
>
{Row}
</FixedSizeList>
);
}DOM nodes rendered: constant ~15 regardless of list size. Memory usage: constant. Scroll performance: 60fps.
useTransition — Keep UI Responsive During Slow Updates
import { useState, useTransition } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Post[]>([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value); // Immediate — keeps input responsive
// Mark as non-urgent — React can interrupt if user types again
startTransition(() => {
const filtered = heavySearch(value); // Expensive operation
setResults(filtered);
});
};
return (
<div>
<input value={query} onChange={handleSearch} placeholder="Search..." />
{isPending && <div className="opacity-50">Updating results...</div>}
<ResultsList results={results} />
</div>
);
}Performance Checklist
| Issue | Solution |
|---|---|
| Component re-renders too often | React.memo + useCallback |
| Expensive calculations re-run | useMemo |
| Large initial bundle | React.lazy + Suspense |
| Thousands of list items | react-window virtual scrolling |
| UI freezes during heavy updates | useTransition + startTransition |
| Images slow to load | Next.js Image component + lazy loading |
| Too many network waterfalls | Server Components + data colocated with component |
Conclusion
React performance optimization is not about memorizing rules — it's about developing intuition. Profile first. Understand why a component re-renders. Then apply the right tool: memo for referential equality, code splitting for bundle size, virtual lists for DOM node count, useTransition for UI responsiveness. Most apps need very little explicit optimization when properly structured with Server Components handling data fetching and Client Components handling only interactive state.



