| name | react-performance |
| description | Complete React performance optimization system. PROACTIVELY activate for: (1) React.memo and memoization, (2) useMemo and useCallback usage, (3) Code splitting with React.lazy, (4) List virtualization (react-window, react-virtuoso), (5) Avoiding unnecessary re-renders, (6) useTransition and useDeferredValue, (7) Bundle optimization, (8) Web Vitals and profiling. Provides: Profiler setup, memoization patterns, lazy loading, virtualization config, state colocation. Ensures optimal React performance with measurable improvements. |
Quick Reference
| Issue | Solution |
|---|
| Unnecessary re-renders | React.memo, useMemo, useCallback |
| Expensive computations | useMemo |
| Large bundles | Code splitting, React.lazy |
| Long lists | Virtualization (react-window) |
| Slow state updates | useTransition, useDeferredValue |
| State too high | State colocation |
| Tool | Purpose |
|---|
| React DevTools Profiler | Component render timing |
web-vitals | CLS, INP, LCP, FCP, TTFB |
| Bundle analyzer | Identify large dependencies |
When to Use This Skill
Use for React performance optimization:
- Diagnosing slow rendering issues
- Implementing memoization correctly
- Setting up code splitting and lazy loading
- Virtualizing long lists for smooth scrolling
- Using concurrent features for responsiveness
- Measuring and improving Web Vitals
For general hooks: see react-hooks-complete
React Performance Optimization
Measuring Performance
React DevTools Profiler
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRenderCallback: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
console.log({
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
});
};
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<MainContent />
</Profiler>
);
}
Web Vitals
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
function reportWebVitals(metric: Metric) {
console.log(metric);
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
});
}
onCLS(reportWebVitals);
onINP(reportWebVitals);
onLCP(reportWebVitals);
onFCP(reportWebVitals);
onTTFB(reportWebVitals);
Memoization
React.memo
import { memo, useState } from 'react';
const ExpensiveList = memo(function ExpensiveList({
items,
onItemClick,
}: {
items: Item[];
onItemClick: (id: string) => void;
}) {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
const DeepCompareList = memo(
function DeepCompareList({ data }: { data: ComplexData }) {
return <div>{/* render */}</div>;
},
(prevProps, nextProps) => {
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.);
}
);
useMemo for Expensive Computations
import { useMemo, useState } from 'react';
function DataTable({ data, filters, sortConfig }: Props) {
const filteredData = useMemo(() => {
console.log('Filtering data...');
return data.filter((item) => {
return Object.entries(filters).every(([key, value]) => {
if (!value) return true;
return item[key]?.toLowerCase().includes(value.toLowerCase());
});
});
}, [data, filters]);
const sortedData = useMemo(() => {
console.log('Sorting data...');
if (!sortConfig.key) return filteredData;
return [...filteredData].sort((a, b) => {
const aVal = a[sortConfig.key];
const bVal = b[sortConfig.key];
(aVal < bVal) sortConfig. === ? - : ;
(aVal > bVal) sortConfig. === ? : -;
;
});
}, [filteredData, sortConfig]);
stats = ( ({
: sortedData.,
: sortedData.( sum + item., ) / sortedData.,
: .(...sortedData.( item.)),
}), [sortedData]);
(
);
}
useCallback for Stable Function References
import { useCallback, useState, memo } from 'react';
const SearchInput = memo(function SearchInput({
onSearch,
}: {
onSearch: (query: string) => void;
}) {
console.log('SearchInput rendered');
return <input onChange={(e) => onSearch(e.target.value)} />;
});
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const handleSearch = useCallback((searchQuery: string) => {
setQuery(searchQuery);
}, []);
return (
<div>
<SearchInput onSearch={handleSearch} />
<ResultsList results={results} />
);
}
Code Splitting
React.lazy and Suspense
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Chart = lazy(() =>
import('./components/Charts').then((module) => ({
default: module.PieChart,
}))
);
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
} />
} />
);
}
Route-Based Splitting
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const DashboardPage = lazy(() => import('./pages/Dashboard'));
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
const handleMouseEnter = () => {
if (to === '/dashboard') {
import('./pages/Dashboard');
}
};
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}
Component-Level Splitting
import { lazy, Suspense, useState } from 'react';
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
const ImageEditor = lazy(() => import('./components/ImageEditor'));
function CreatePost() {
const [showEditor, setShowEditor] = useState(false);
const [showImageEditor, setShowImageEditor] = useState(false);
return (
<div>
<button onClick={() => setShowEditor(true)}>Open Editor</button>
{showEditor && (
<Suspense fallback={<EditorSkeleton />}>
<RichTextEditor />
</Suspense>
)}
<button onClick={() => setShowImageEditor(true)}>Edit Image</button>
{showImageEditor && (
<Suspense =< />}>
)}
);
}
List Virtualization
react-window
import { FixedSizeList, VariableSizeList } from 'react-window';
function VirtualizedList({ items }: { items: Item[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
function VirtualizedVariableList({ items }: { items: Item[] }) {
const getItemSize = (index: number) => {
return items[index].content.length > 100 ? 100 : 50;
};
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
);
(
);
}
react-virtuoso
import { Virtuoso, VirtuosoGrid } from 'react-virtuoso';
function VirtuosoList({ items }: { items: Item[] }) {
return (
<Virtuoso
style={{ height: '600px' }}
totalCount={items.length}
itemContent={(index) => (
<div className="item">
<h3>{items[index].title}</h3>
<p>{items[index].description}</p>
</div>
)}
/>
);
}
function GroupedList({ groups }: { groups: Group[] }) {
const groupCounts = groups.map((g) => g.items.length);
const allItems = groups.flatMap((g) => g.items);
return (
<Virtuoso
style={{ '' }}
=
= => (
{groups[index].name}
)}
itemContent={(index) => (
{allItems[index].name}
)}
/>
);
}
Avoiding Unnecessary Renders
State Colocation
function App() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div>
<Header /> {/* Re-renders when searchQuery changes */}
<SearchSection query={searchQuery} setQuery={setSearchQuery} />
<Footer /> {/* Re-renders when searchQuery changes */}
</div>
);
}
function App() {
return (
<div>
<Header />
<SearchSection /> {/* Manages its own state */}
<Footer />
</div>
);
}
function SearchSection() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div>
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
< = />
);
}
Children as Props
function Modal({ isOpen, children }: Props) {
const [position, setPosition] = useState({ x: 0, y: 0 });
return isOpen ? (
<div style={{ left: position.x, top: position.y }}>
{children} {/* Re-renders when position changes */}
</div>
) : null;
}
function Modal({ isOpen, children }: Props) {
const [position, setPosition] = useState({ x: 0, y: 0 });
return isOpen ? (
<div style={{ left: position.x, top: position.y }}>
{children}
</div>
) : null;
}
Composition Pattern
function SlowComponent({ isOpen }: { isOpen: boolean }) {
const [count, setCount] = useState(0);
const [items] = useState(generateLargeList());
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveTree items={items} /> {/* Re-renders on count change */}
</div>
);
}
function FastComponent() {
return (
<div>
<Counter />
<ExpensiveTreeWrapper />
</div>
);
}
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
() {
[items] = (());
;
}
Concurrent Features
useTransition for Non-Blocking Updates
import { useState, useTransition, memo } from 'react';
const SlowList = memo(function SlowList({ text }: { text: string }) {
const items = [];
for (let i = 0; i < 10000; i++) {
items.push(<li key={i}>{text}</li>);
}
return <ul>{items}</ul>;
});
function SearchWithTransition() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
startTransition(() => {
setQuery(value);
});
};
(
);
}
useDeferredValue for Deferred Rendering
import { useState, useDeferredValue, memo } from 'react';
const SearchResults = memo(function SearchResults({ query }: { query: string }) {
const results = expensiveSearch(query);
return (
<ul>
{results.map((result) => (
<li key={result.id}>{result.name}</li>
))}
</ul>
);
});
function SearchWithDeferredValue() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<div style={{ opacity: isStale ? 0.7 : }}>
);
}
Image Optimization
Lazy Loading Images
function LazyImage({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
return (
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
{...props}
/>
);
}
function OptimizedImage({ src, alt, width, height }: Props) {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div style={{ position: 'relative', width, height }}>
{!isLoaded && <div className="placeholder skeleton" />}
<img
src={src}
alt={alt}
loading="lazy"
onLoad={() => setIsLoaded(true)}
style={{ opacity: isLoaded ? 1 : 0 }}
/>
</div>
);
}
Responsive Images
function ResponsiveImage({ src, alt }: { src: string; alt: string }) {
return (
<picture>
<source
media="(max-width: 640px)"
srcSet={`${src}?w=640 1x, ${src}?w=1280 2x`}
/>
<source
media="(max-width: 1024px)"
srcSet={`${src}?w=1024 1x, ${src}?w=2048 2x`}
/>
<img
src={`${src}?w=1920`}
alt={alt}
loading="lazy"
decoding="async"
/>
</picture>
);
}
Bundle Optimization
Analyzing Bundle
npx webpack-bundle-analyzer stats.json
npx vite-bundle-analyzer
Tree Shaking
import _ from 'lodash';
const sorted = _.sortBy(items, 'name');
import sortBy from 'lodash/sortBy';
const sorted = sortBy(items, 'name');
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
Dynamic Imports
async function handleExport() {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF();
doc.text('Hello world!', 10, 10);
doc.save('document.pdf');
}
async function handleChart() {
const { Chart } = await import('chart.js/auto');
new Chart(ctx, config);
}
Best Practices Summary
| Issue | Solution |
|---|
| Unnecessary re-renders | React.memo, useMemo, useCallback |
| Expensive computations | useMemo |
| Large bundles | Code splitting, lazy loading |
| Long lists | Virtualization |
| Slow state updates | useTransition, useDeferredValue |
| Image performance | Lazy loading, responsive images |
| State too high | State colocation |
Media Element Performance
Video and audio elements require special attention in React because the browser's media pipeline is stateful and expensive to initialize. Unlike most DOM elements, a <video> element manages hardware decoder instances, buffered data, and playback state. When React unmounts and remounts a video element, the browser must tear down the entire decoder pipeline and restart it from scratch --- a process called decoder churn.
Why Video Re-renders Are Costly
On mobile devices, decoder churn is especially destructive:
| Impact | Desktop | Mobile |
|---|
| Decoder initialization | ~50ms | ~200-500ms |
| Simultaneous decoders | 8-16 | 3-4 (hardware limited) |
| Battery drain per restart | Negligible | Measurable |
| Visual glitch | Brief flash | Black frame + delay |
When a parent component re-renders and causes a <video> element to remount, the browser:
- Destroys the existing hardware decoder instance
- Releases all buffered video data
- Allocates a new decoder (may fail if device limit reached)
- Re-fetches and re-buffers the video stream
- Restarts playback from the beginning (or seeks back)
Preventing Video Remounting with Stable Keys
The most common cause of video remounting is unstable keys or conditional rendering that changes the component tree structure:
function VideoFeed({ videos, filter }: Props) {
const filtered = videos.filter(v => v.category === filter);
return (
<div>
{filtered.map((video, index) => (
// Using index as key means elements shift and remount
<video key={index} src={video.src} />
))}
</div>
);
}
function VideoFeed({ videos, filter }: Props) {
const filtered = videos.filter(v => v.category === filter);
return (
<div>
{filtered.map((video) => (
// Stable ID keeps the same DOM element across re-renders
<video key={video.id} src={video.src} />
))}
</div>
);
}
Ref-Based Video Element Management
Use useRef to interact with video elements imperatively, avoiding state-driven patterns that trigger re-renders:
import { useRef, useCallback, memo } from 'react';
const VideoPlayer = memo(function VideoPlayer({
src,
poster,
}: {
src: string;
poster?: string;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const play = useCallback(() => {
videoRef.current?.play();
}, []);
const pause = useCallback(() => {
videoRef.current?.pause();
}, []);
const seek = useCallback((time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
}
}, []);
return (
<div>
<video
ref={videoRef}
src={src}
poster={poster}
playsInline
preload="metadata"
onPlay={() => {/* update UI without re-rendering video */}}
onPause={() => {/* update UI without re-rendering video */}}
/>
Play
Pause
);
});
Memoizing Video Components
Wrap video components with React.memo and memoize all non-primitive props to prevent unnecessary re-renders:
import { memo, useMemo, useCallback } from 'react';
interface VideoCardProps {
id: string;
src: string;
title: string;
onPlay: (id: string) => void;
onTimeUpdate: (id: string, time: number) => void;
}
const VideoCard = memo(function VideoCard({
id,
src,
title,
onPlay,
onTimeUpdate,
}: VideoCardProps) {
const sourceProps = useMemo(
() => ({ src, type: 'video/mp4' }),
[src]
);
const handlePlay = useCallback(() => onPlay(id), [onPlay, id]);
const handleTimeUpdate = useCallback(
(e: React.SyntheticEvent<HTMLVideoElement>) => {
onTimeUpdate(id, e.currentTarget.);
},
[onTimeUpdate, id]
);
(
);
});
() {
handlePlay = ( {
.(, id);
}, []);
handleTimeUpdate = ( {
progressRef..(id, time);
}, []);
progressRef = ( <, >());
(
);
}
Portal Lifecycle and Video Elements
React portals can cause unexpected video remounting when the portal's parent moves in the DOM tree. The portal content unmounts and remounts, destroying the video decoder:
import { useState, useRef, useEffect, createPortal } from 'react';
function VideoWithModal({ src }: { src: string }) {
const [isModal, setIsModal] = useState(false);
if (isModal) {
return createPortal(
<div className="modal">
{/* This creates a NEW video element, losing playback state */}
<video src={src} playsInline />
<button onClick={() => setIsModal(false)}>Close</button>
</div>,
document.body
);
}
return (
<div>
<video src={src} playsInline />
<button onClick={() => setIsModal(true)}>Expand</button>
</div>
);
}
function VideoWithModal() {
[isModal, setIsModal] = ();
videoRef = useRef<>();
inlineContainerRef = useRef<>();
modalContainerRef = useRef<>();
( {
video = videoRef.;
(!video) ;
target = isModal
? modalContainerRef.
: inlineContainerRef.;
target?.(video);
}, [isModal]);
(
);
}
Intersection Observer for Lazy Video Loading
On mobile, loading all videos simultaneously wastes bandwidth, drains battery, and can exhaust the device's limited hardware decoder slots. Use Intersection Observer to load and play videos only when visible:
import { useRef, useEffect, useState, memo } from 'react';
const LazyVideo = memo(function LazyVideo({
src,
poster,
preloadMargin = '200px',
}: {
src: string;
poster?: string;
preloadMargin?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
videoRef.current?.play().catch(() => {
});
} else {
videoRef.current?.();
}
},
{
: preloadMargin,
: ,
}
);
observer.(container);
observer.();
}, [preloadMargin]);
(
);
});
() {
(
);
}
Mobile-Specific Video Attributes
Always include these attributes for proper mobile video behavior:
<video
ref={videoRef}
src={src}
playsInline
muted
preload="metadata"
poster={posterUrl}
disablePictureInPicture
controlsList="nodownload nofullscreen noremoteplayback"
/>
Multiple Simultaneous Video Decoders
Mobile devices typically support only 3-4 simultaneous hardware video decoders. Exceeding this limit causes videos to fall back to software decoding (slow, battery-draining) or fail entirely:
import { useRef, useCallback } from 'react';
const MAX_ACTIVE_VIDEOS = 3;
function useVideoDecoderPool() {
const activeVideos = useRef<Set<HTMLVideoElement>>(new Set());
const activate = useCallback((video: HTMLVideoElement) => {
if (activeVideos.current.size >= MAX_ACTIVE_VIDEOS) {
const oldest = activeVideos.current.values().next().value;
if (oldest) {
oldest.pause();
oldest.removeAttribute('src');
oldest.load();
activeVideos.current.delete(oldest);
}
}
activeVideos.current.add(video);
}, []);
const deactivate = useCallback((video: HTMLVideoElement) => {
activeVideos.current.(video);
}, []);
{ activate, deactivate };
}
Additional References
For comprehensive guides on specific optimization techniques, see:
references/virtualization-guide.md - Complete guide to virtualized lists with react-window, react-virtuoso, and @tanstack/react-virtual