React performance, in short
Focus on fewer renders, fewer DOM nodes, fewer bytes, and deferring non‑critical work.
Measure first
- Use Performance panel and React Profiler
- Track Core Web Vitals (LCP, INP, CLS)
Cut re‑renders
- Stabilize props with useMemo/useCallback only where profiling shows churn
- Split large context or use selector hooks
- Prefer derived UI over duplicating state
Render less
- Virtualize long lists (react‑window)
- Code‑split heavy routes/widgets (React.lazy + Suspense)
Ship fewer bytes
- Optimize images (sizes/srcset, modern formats, lazy)
- Cache API responses; debounce inputs; compress over the wire
Keep interactions snappy
- useTransition/useDeferredValue for expensive updates during typing
- Avoid layout thrash; prefer transform/opacity for animation
Checklist: stable props, virtualize big lists, optimize images, code‑split, verify with Web Vitals.
const ExpensiveList = memo(({ items }) => {
const sorted = useMemo(() => items.slice().sort(), [items]);
const handleClick = useCallback((id) => console.log(id), []);
return sorted.map((id) => (
<button key={id} onClick={() => handleClick(id)}>
{id}
</button>
));
});
Virtualize long lists
Use windowing libraries for lists > 100 items.
Code-splitting
Lazy-load non-critical routes and components with React.lazy
and Suspense
.
Image optimization
Serve responsive images, use modern formats (WebP/AVIF), and lazy-load offscreen images.
Measure first
Profile with the React DevTools Profiler and browser performance panel before changing code.
Profile React the right way
Start with the Performance tab and React Profiler:
- Record interactions that feel slow (route change, list filter, opening modals).
- Identify components with long render times and high commit counts.
- Check for “cascading re-renders” where a parent re-renders many children.
If the flamegraph shows many renders without state changes, you likely have unstable props or context redraws.
Prevent unnecessary re-renders
- Pass stable references: move inline objects/functions out or wrap with
useMemo
/useCallback
when they cause real re-render cost. - Split context: large contexts force wide re-renders; split by concern or use selector-based contexts.
- Prefer derived UI over derived state: compute UI from raw state rather than storing multiple copies.
// Bad: new object every render → child re-renders
<Chart options={{ color: theme.primary }} />;
// Good: stable memoized value
const chartOptions = useMemo(() => ({ color: theme.primary }), [theme.primary]);
<Chart options={chartOptions} />;
React.memo and when to use it
React.memo
can cut re-renders of pure components. It helps most when:
- Props rarely change
- Component work is expensive (formatting, heavy DOM, large lists)
Beware of overusing it—memoization has a comparison cost. Add it where profiling shows benefit.
const Card = React.memo(function Card({ title, children }) {
return (
<section className="blog-card rounded-xl p-6">
<h3 className="font-bold mb-2">{title}</h3>
{children}
</section>
);
});
Virtualize really long lists
For 100+ items, render only what’s visible. Libraries like react-window
or react-virtualized
reduce DOM nodes dramatically.
npm i react-window
import { FixedSizeList as List } from "react-window";
const Row = ({ index, style, data }) => (
<div style={style}>{data[index].title}</div>
);
export function PostsVirtualized({ items }) {
return (
<List
height={480}
itemCount={items.length}
itemSize={56}
width={"100%"}
itemData={items}
>
{Row}
</List>
);
}
Optimize images aggressively
- Always include intrinsic
width
andheight
or container ratios to reduce CLS. - Use
loading="lazy"
anddecoding="async"
(already in this project’sLazyImage
). - Serve multiple sizes with
srcset
/sizes
when possible.
<img
src="/img/hero-1280.jpg"
srcset="
/img/hero-640.jpg 640w,
/img/hero-960.jpg 960w,
/img/hero-1280.jpg 1280w
"
sizes="(max-width: 768px) 100vw, 768px"
alt="Hero"
loading="lazy"
decoding="async"
/>
Code-split non-critical UI
Lazy-load heavy widgets and route-level bundles:
const Comments = React.lazy(() => import("./Comments"));
export function PostFooter() {
return (
<React.Suspense fallback={<div>Loading comments…</div>}>
<Comments />
</React.Suspense>
);
}
Network and runtime tweaks
- Cache API responses; debounce input-driven queries.
- Avoid large JSON in memory; stream where possible.
- Use a CDN for static assets; prefer Brotli compression.
Tie improvements to Web Vitals
Track LCP, INP, and CLS. This project already includes a perf utility (src/utils/performance.js
). Ensure analytics hooks are configured so you can verify impact.
Schedule work to keep interactions snappy
Use concurrent features to prevent UI jank during keystrokes and navigation.
import { useDeferredValue, useTransition } from "react";
export function Search({ list }) {
const [term, setTerm] = React.useState("");
const [isPending, startTransition] = useTransition();
// Defer filtering to keep typing responsive
const deferredTerm = useDeferredValue(term);
const results = React.useMemo(
() => list.filter((x) => x.includes(deferredTerm)),
[list, deferredTerm]
);
function onChange(e) {
const value = e.target.value;
startTransition(() => setTerm(value));
}
return (
<div>
<input value={term} onChange={onChange} />
{isPending && <span>Updating…</span>}
<ul>
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</div>
);
}
Avoid layout thrashing
Batch reads and writes and minimize sync layout measurements in hot paths.
// Bad: forces layout on every mousemove
element.addEventListener("mousemove", () => {
const { left } = element.getBoundingClientRect(); // read
element.style.transform = `translateX(${left}px)`; // write
});
// Better: requestAnimationFrame and cached reads
let frame = 0;
element.addEventListener("mousemove", () => {
if (frame) return;
frame = requestAnimationFrame(() => {
const left = cachedLeft; // from a scheduled read
element.style.transform = `translateX(${left}px)`;
frame = 0;
});
});
Use CSS where possible (e.g., transform
, opacity
) and hint with will-change
for highly animated elements.
Data fetching and Suspense boundaries
Wrap slow subtrees in Suspense with lightweight fallbacks to keep the page interactive.
const Pricing = React.lazy(() => import("./Pricing"));
export function Page() {
return (
<React.Suspense fallback={<div className="h-32 animate-pulse" />}>
<Pricing />
</React.Suspense>
);
}
Hydration pitfalls
- Avoid reading
window
/document
during initial render; guard with effects or environment checks. - Keep server and client markup consistent (no randomized IDs without stable seeds).
- Provide explicit
width
/height
to media to avoid layout shifts post-hydration.
Checklist
- Stable props and memoization for hot paths
- Virtualized lists for large collections
- Optimized images with proper sizing and lazy loading
- Strategic code-splitting for heavy components
- Monitored Web Vitals before/after changes
Conclusion
Measure first, optimize where it hurts, and verify with profiling tools. Most wins come from reducing work (renders, DOM, bytes) and deferring what the user doesn’t need immediately.