
React Performance
- 54 installs
- 50 repo stars
- Updated June 18, 2026
- josiahsiegel/claude-plugin-marketplace
Helps with frontend development tasks.
About
react-performance is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted development.
- react-performance
- Frontend Development
- AI-coding skill
React Performance by the numbers
- 54 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,275 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/josiahsiegel/claude-plugin-marketplace --skill react-performanceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 54 |
|---|---|
| repo stars | ★ 50 |
| Last updated | June 18, 2026 |
| Repository | josiahsiegel/claude-plugin-marketplace ↗ |
What it does
Helps with frontend development tasks.
Files
React Performance Skill
Use this skill to diagnose and improve React rendering, bundle size, Web Vitals, list performance, image/media loading, state placement, and responsiveness with measurable before/after evidence.
When to Use This Skill
Use when the user asks for tasks covered by the frontmatter triggers, especially implementation guidance, debugging, architecture choices, production hardening, or performance-sensitive decisions in this domain. Start from this orchestrator, then load the focused reference file that matches the requested detail level.
Core Workflow
1. Measure first with React DevTools Profiler, Web Vitals, browser performance tools, or a bundle analyzer before changing code. 2. Identify the bottleneck class: unnecessary re-renders, expensive calculations, large lists, large bundles, slow interactions, image/media loading, or state placed too high. 3. Apply the smallest targeted fix: state colocation, composition, memoization, virtualization, lazy loading, dynamic import, or concurrent rendering APIs. 4. For media-heavy UIs, protect video element identity, avoid remounting, use refs for imperative controls, and lazy-load sources with IntersectionObserver. 5. Re-measure after each change and keep only optimizations that improve duration, Web Vitals, memory, decoder churn, or bundle size.
Key Gotchas
- Memoization has overhead; use
React.memo,useMemo, anduseCallbackfor measured hotspots or stable prop boundaries, not everywhere. - Custom
React.memocomparisons can be slower than rendering if they perform deep equality on large structures. - Index keys in filtered/reordered lists can cause remounts and state loss, especially costly for video elements.
- Video elements are stateful browser resources; remounting can destroy hardware decoders and buffered playback state.
- Concurrent features improve responsiveness but do not make expensive work disappear; they schedule non-urgent work.
Reference Map
- references/react-performance-complete-guide.md - Full original guide covering measurement, memoization, code splitting, virtualization, render avoidance, concurrent features, images, bundles, media elements, decoder pools, and mobile video performance.
- references/virtualization-guide.md - Detailed virtualized-list guide already maintained for this skill.
Response Guidance
- Preserve the user's existing framework, library, and tooling choices unless there is a clear compatibility or performance reason to suggest an alternative.
- Give copy-pasteable code only for the exact task at hand; otherwise point to the relevant reference section.
- Call out tradeoffs, failure modes, and verification steps for production workflows.
- Prefer accessible, maintainable, measurable solutions over clever micro-optimizations.
React Performance Complete Guide
This reference preserves the detailed guide content extracted from SKILL.md so the top-level skill can remain a lean orchestrator while retaining all examples, tables, recipes, and troubleshooting guidance.
---
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
// Wrap components with Profiler for measurement
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRenderCallback: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
console.log({
id,
phase,
actualDuration, // Time spent rendering
baseDuration, // Estimated time without memoization
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);
// Send to analytics
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
});
}
onCLS(reportWebVitals); // Cumulative Layout Shift
onINP(reportWebVitals); // Interaction to Next Paint
onLCP(reportWebVitals); // Largest Contentful Paint
onFCP(reportWebVitals); // First Contentful Paint
onTTFB(reportWebVitals); // Time to First ByteMemoization
React.memo
import { memo, useState } from 'react';
// Memoized component - only re-renders when props change
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>
);
});
// With custom comparison
const DeepCompareList = memo(
function DeepCompareList({ data }: { data: ComplexData }) {
return <div>{/* render */}</div>;
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
}
);useMemo for Expensive Computations
import { useMemo, useState } from 'react';
function DataTable({ data, filters, sortConfig }: Props) {
// Memoize filtered data
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]);
// Memoize sorted data
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];
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [filteredData, sortConfig]);
// Memoize statistics
const stats = useMemo(() => ({
total: sortedData.length,
average: sortedData.reduce((sum, item) => sum + item.value, 0) / sortedData.length,
max: Math.max(...sortedData.map((item) => item.value)),
}), [sortedData]);
return (
<div>
<Stats data={stats} />
<Table data={sortedData} />
</div>
);
}useCallback for Stable Function References
import { useCallback, useState, memo } from 'react';
// Child component that should not re-render unnecessarily
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[]>([]);
// Stable callback reference
const handleSearch = useCallback((searchQuery: string) => {
setQuery(searchQuery);
// Perform search...
}, []);
// Without useCallback, SearchInput would re-render on every parent render
return (
<div>
<SearchInput onSearch={handleSearch} />
<ResultsList results={results} />
</div>
);
}Code Splitting
React.lazy and Suspense
import { lazy, Suspense } from 'react';
// Lazy load components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
// With named exports
const Chart = lazy(() =>
import('./components/Charts').then((module) => ({
default: module.PieChart,
}))
);
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}Route-Based Splitting
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Preload on hover
const DashboardPage = lazy(() => import('./pages/Dashboard'));
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
const handleMouseEnter = () => {
// Preload component on hover
if (to === '/dashboard') {
import('./pages/Dashboard');
}
};
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}Component-Level Splitting
import { lazy, Suspense, useState } from 'react';
// Heavy component loaded only when needed
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 fallback={<ImageEditorSkeleton />}>
<ImageEditor />
</Suspense>
)}
</div>
);
}List Virtualization
react-window
import { FixedSizeList, VariableSizeList } from 'react-window';
// Fixed size items
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>
);
}
// Variable size items
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 }) => (
<div style={style}>
<h3>{items[index].title}</h3>
<p>{items[index].content}</p>
</div>
);
return (
<VariableSizeList
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
}react-virtuoso
import { Virtuoso, VirtuosoGrid } from 'react-virtuoso';
// Simple virtualized list
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>
)}
/>
);
}
// With grouping
function GroupedList({ groups }: { groups: Group[] }) {
const groupCounts = groups.map((g) => g.items.length);
const allItems = groups.flatMap((g) => g.items);
return (
<Virtuoso
style={{ height: '600px' }}
groupCounts={groupCounts}
groupContent={(index) => (
<div className="group-header">{groups[index].name}</div>
)}
itemContent={(index) => (
<div className="item">{allItems[index].name}</div>
)}
/>
);
}Avoiding Unnecessary Renders
State Colocation
// Bad - state too high in tree
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>
);
}
// Good - state colocated with usage
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)} />
<SearchResults query={searchQuery} />
</div>
);
}Children as Props
// Bad - re-renders children on state change
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;
}
// Good - children are stable
function Modal({ isOpen, children }: Props) {
const [position, setPosition] = useState({ x: 0, y: 0 });
// children is a prop, not re-created on render
return isOpen ? (
<div style={{ left: position.x, top: position.y }}>
{children}
</div>
) : null;
}Composition Pattern
// Bad - entire tree re-renders
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>
);
}
// Good - expensive component isolated
function FastComponent() {
return (
<div>
<Counter />
<ExpensiveTreeWrapper />
</div>
);
}
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
function ExpensiveTreeWrapper() {
const [items] = useState(generateLargeList());
return <ExpensiveTree items={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;
// Urgent update - keep input responsive
setQuery(value);
// Non-urgent update - can be interrupted
startTransition(() => {
setQuery(value);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<SlowList text={query} />
</div>
</div>
);
}useDeferredValue for Deferred Rendering
import { useState, useDeferredValue, memo } from 'react';
const SearchResults = memo(function SearchResults({ query }: { query: string }) {
// Expensive search/filter operation
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);
// Show stale indicator
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</div>
);
}Image Optimization
Lazy Loading Images
function LazyImage({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
return (
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
{...props}
/>
);
}
// With placeholder
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
# With webpack
npx webpack-bundle-analyzer stats.json
# With Vite
npx vite-bundle-analyzerTree Shaking
// Bad - imports entire library
import _ from 'lodash';
const sorted = _.sortBy(items, 'name');
// Good - import only what you need
import sortBy from 'lodash/sortBy';
const sorted = sortBy(items, 'name');
// Best - use native methods when possible
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));Dynamic Imports
// Load heavy libraries on demand
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: 1. Destroys the existing hardware decoder instance 2. Releases all buffered video data 3. Allocates a new decoder (may fail if device limit reached) 4. Re-fetches and re-buffers the video stream 5. 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:
// BAD - video remounts every time filter changes
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>
);
}
// GOOD - stable keys prevent remounting
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);
// Imperative control avoids re-renders
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 */}}
/>
<button onClick={play}>Play</button>
<button onClick={pause}>Pause</button>
</div>
);
});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) {
// Stable source object prevents <source> element remount
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.currentTime);
},
[onTimeUpdate, id]
);
return (
<div>
<h3>{title}</h3>
<video
playsInline
preload="metadata"
onPlay={handlePlay}
onTimeUpdate={handleTimeUpdate}
>
<source src={sourceProps.src} type={sourceProps.type} />
</video>
</div>
);
});
// Parent component with stable callbacks
function VideoList({ videos }: { videos: Video[] }) {
const handlePlay = useCallback((id: string) => {
console.log('Playing:', id);
}, []);
const handleTimeUpdate = useCallback((id: string, time: number) => {
// Update progress without triggering re-render of video components
progressRef.current.set(id, time);
}, []);
const progressRef = useRef(new Map<string, number>());
return (
<div>
{videos.map((video) => (
<VideoCard
key={video.id}
id={video.id}
src={video.src}
title={video.title}
onPlay={handlePlay}
onTimeUpdate={handleTimeUpdate}
/>
))}
</div>
);
}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';
// BAD - video remounts when switching between inline and modal
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>
);
}
// GOOD - move the DOM node instead of recreating it
function VideoWithModal({ src }: { src: string }) {
const [isModal, setIsModal] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const inlineContainerRef = useRef<HTMLDivElement>(null);
const modalContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Move the existing video DOM node instead of recreating it
const target = isModal
? modalContainerRef.current
: inlineContainerRef.current;
target?.appendChild(video);
}, [isModal]);
return (
<>
{/* Video element created once, moved between containers */}
<video
ref={videoRef}
src={src}
playsInline
style={{ display: 'none' }}
/>
<div ref={inlineContainerRef}>
{!isModal && (
<button onClick={() => setIsModal(true)}>Expand</button>
)}
</div>
{isModal &&
createPortal(
<div className="modal">
<div ref={modalContainerRef} />
<button onClick={() => setIsModal(false)}>Close</button>
</div>,
document.body
)}
</>
);
}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);
// Optionally auto-play when visible
videoRef.current?.play().catch(() => {
// Autoplay blocked by browser policy - this is expected
});
} else {
// Pause when scrolled out of view to save resources
videoRef.current?.pause();
}
},
{
rootMargin: preloadMargin, // Start loading before fully visible
threshold: 0.25,
}
);
observer.observe(container);
return () => observer.disconnect();
}, [preloadMargin]);
return (
<div ref={containerRef} style={{ aspectRatio: '16/9' }}>
<video
ref={videoRef}
// Only set src when near viewport to prevent premature loading
src={isVisible ? src : undefined}
poster={poster}
playsInline
muted
loop
preload={isVisible ? 'auto' : 'none'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
);
});
// Usage in a feed - only visible videos consume decoder slots
function VideoFeed({ videos }: { videos: VideoItem[] }) {
return (
<div>
{videos.map((video) => (
<LazyVideo
key={video.id}
src={video.src}
poster={video.poster}
/>
))}
</div>
);
}Mobile-Specific Video Attributes
Always include these attributes for proper mobile video behavior:
<video
ref={videoRef}
src={src}
playsInline // REQUIRED: prevents iOS Safari fullscreen takeover
muted // Required for autoplay on all mobile browsers
preload="metadata" // Load dimensions/duration only, not full video
poster={posterUrl} // Show image while video loads (saves bandwidth)
disablePictureInPicture // Prevent PiP on mobile if unwanted
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';
// Limit active video decoders across the application
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) {
// Pause the oldest active video to free a decoder slot
const oldest = activeVideos.current.values().next().value;
if (oldest) {
oldest.pause();
oldest.removeAttribute('src');
oldest.load(); // Release the decoder
activeVideos.current.delete(oldest);
}
}
activeVideos.current.add(video);
}, []);
const deactivate = useCallback((video: HTMLVideoElement) => {
activeVideos.current.delete(video);
}, []);
return { 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
React Virtualization Guide
Complete guide to virtualized lists for handling large datasets efficiently.
Why Virtualization?
| Items | Without Virtualization | With Virtualization |
|---|---|---|
| 100 | ~20ms | ~5ms |
| 1,000 | ~200ms | ~10ms |
| 10,000 | ~2000ms (unusable) | ~15ms |
| 100,000 | Crash | ~20ms |
Virtualization only renders visible items + buffer, making performance constant regardless of list size.
Libraries Comparison
| Library | Size | Features | Best For |
|---|---|---|---|
| react-window | 6kb | Fixed/Variable size lists | Simple lists |
| react-virtuoso | 15kb | Auto-sizing, grouping, tables | Complex lists |
| @tanstack/react-virtual | 5kb | Headless, flexible | Custom implementations |
react-window Implementation
Installation
npm install react-window
npm install -D @types/react-windowFixedSizeList - Basic Usage
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { memo } from 'react';
interface Item {
id: string;
name: string;
email: string;
}
interface RowProps extends ListChildComponentProps<Item[]> {}
// IMPORTANT: Memoize row component
const Row = memo(function Row({ index, style, data }: RowProps) {
const item = data[index];
return (
<div style={style} className="flex items-center px-4 border-b">
<span className="flex-1">{item.name}</span>
<span className="text-gray-500">{item.email}</span>
</div>
);
});
function UserList({ users }: { users: Item[] }) {
return (
<FixedSizeList
height={600} // Container height
width="100%" // Container width
itemCount={users.length}
itemSize={50} // Row height in pixels
itemData={users} // Data passed to Row
>
{Row}
</FixedSizeList>
);
}FixedSizeList - With Interaction
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { memo, useCallback } from 'react';
interface Item {
id: string;
name: string;
status: 'active' | 'inactive';
}
interface RowData {
items: Item[];
onSelect: (item: Item) => void;
onDelete: (id: string) => void;
selectedId: string | null;
}
const Row = memo(function Row({
index,
style,
data,
}: ListChildComponentProps<RowData>) {
const { items, onSelect, onDelete, selectedId } = data;
const item = items[index];
const isSelected = item.id === selectedId;
return (
<div
style={style}
className={`flex items-center px-4 border-b cursor-pointer ${
isSelected ? 'bg-blue-100' : 'hover:bg-gray-50'
}`}
onClick={() => onSelect(item)}
>
<span className="flex-1 font-medium">{item.name}</span>
<span
className={`px-2 py-1 rounded text-sm ${
item.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}
>
{item.status}
</span>
<button
className="ml-2 p-1 text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
>
Delete
</button>
</div>
);
});
function InteractiveList({ items }: { items: Item[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const handleSelect = useCallback((item: Item) => {
setSelectedId(item.id);
}, []);
const handleDelete = useCallback((id: string) => {
// Handle deletion
}, []);
// IMPORTANT: Memoize itemData to prevent unnecessary re-renders
const itemData = useMemo(
() => ({
items,
onSelect: handleSelect,
onDelete: handleDelete,
selectedId,
}),
[items, handleSelect, handleDelete, selectedId]
);
return (
<FixedSizeList
height={400}
width="100%"
itemCount={items.length}
itemSize={56}
itemData={itemData}
>
{Row}
</FixedSizeList>
);
}VariableSizeList - Dynamic Heights
import { VariableSizeList, ListChildComponentProps } from 'react-window';
import { memo, useCallback, useRef } from 'react';
interface Message {
id: string;
text: string;
sender: string;
timestamp: Date;
}
const Row = memo(function Row({
index,
style,
data,
}: ListChildComponentProps<Message[]>) {
const message = data[index];
return (
<div style={style} className="px-4 py-2">
<div className="flex justify-between">
<span className="font-medium">{message.sender}</span>
<span className="text-gray-400 text-sm">
{message.timestamp.toLocaleTimeString()}
</span>
</div>
<p className="text-gray-700">{message.text}</p>
</div>
);
});
function ChatMessages({ messages }: { messages: Message[] }) {
const listRef = useRef<VariableSizeList>(null);
// Calculate height based on content
const getItemSize = useCallback(
(index: number) => {
const message = messages[index];
// Base height + lines of text
const lineHeight = 24;
const lines = Math.ceil(message.text.length / 60);
return 40 + lines * lineHeight;
},
[messages]
);
// Reset cache when messages change
useEffect(() => {
listRef.current?.resetAfterIndex(0);
}, [messages]);
return (
<VariableSizeList
ref={listRef}
height={500}
width="100%"
itemCount={messages.length}
itemSize={getItemSize}
itemData={messages}
>
{Row}
</VariableSizeList>
);
}FixedSizeGrid - 2D Virtualization
import { FixedSizeGrid, GridChildComponentProps } from 'react-window';
import { memo } from 'react';
interface Product {
id: string;
name: string;
price: number;
image: string;
}
const Cell = memo(function Cell({
columnIndex,
rowIndex,
style,
data,
}: GridChildComponentProps<{ products: Product[]; columns: number }>) {
const { products, columns } = data;
const index = rowIndex * columns + columnIndex;
const product = products[index];
if (!product) return <div style={style} />;
return (
<div style={style} className="p-2">
<div className="border rounded-lg p-4 h-full">
<img
src={product.image}
alt={product.name}
className="w-full h-32 object-cover rounded"
/>
<h3 className="mt-2 font-medium">{product.name}</h3>
<p className="text-green-600">${product.price}</p>
</div>
</div>
);
});
function ProductGrid({ products }: { products: Product[] }) {
const columns = 4;
const rows = Math.ceil(products.length / columns);
return (
<FixedSizeGrid
height={600}
width={800}
columnCount={columns}
columnWidth={200}
rowCount={rows}
rowHeight={250}
itemData={{ products, columns }}
>
{Cell}
</FixedSizeGrid>
);
}react-virtuoso Implementation
Installation
npm install react-virtuosoBasic List - Auto Height
import { Virtuoso } from 'react-virtuoso';
interface Item {
id: string;
title: string;
description: string;
}
function AutoSizeList({ items }: { items: Item[] }) {
return (
<Virtuoso
style={{ height: 600 }}
data={items}
itemContent={(index, item) => (
<div className="p-4 border-b">
<h3 className="font-medium">{item.title}</h3>
<p className="text-gray-600">{item.description}</p>
</div>
)}
/>
);
}Infinite Scroll
import { Virtuoso } from 'react-virtuoso';
import { useState, useCallback } from 'react';
interface Item {
id: string;
name: string;
}
function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
const newItems = await fetchItems(items.length, 20);
if (newItems.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...newItems]);
}, [items.length]);
return (
<Virtuoso
style={{ height: 600 }}
data={items}
endReached={hasMore ? loadMore : undefined}
itemContent={(index, item) => (
<div className="p-4 border-b">{item.name}</div>
)}
components={{
Footer: () =>
hasMore ? (
<div className="p-4 text-center">Loading more...</div>
) : (
<div className="p-4 text-center text-gray-500">No more items</div>
),
}}
/>
);
}Grouped List
import { GroupedVirtuoso } from 'react-virtuoso';
interface Contact {
name: string;
email: string;
}
interface ContactGroup {
letter: string;
contacts: Contact[];
}
function GroupedContactList({ groups }: { groups: ContactGroup[] }) {
const groupCounts = groups.map((g) => g.contacts.length);
const allContacts = groups.flatMap((g) => g.contacts);
return (
<GroupedVirtuoso
style={{ height: 600 }}
groupCounts={groupCounts}
groupContent={(index) => (
<div className="bg-gray-100 px-4 py-2 font-bold sticky top-0">
{groups[index].letter}
</div>
)}
itemContent={(index) => (
<div className="px-4 py-3 border-b">
<div className="font-medium">{allContacts[index].name}</div>
<div className="text-gray-500 text-sm">{allContacts[index].email}</div>
</div>
)}
/>
);
}Virtuoso Table
import { TableVirtuoso } from 'react-virtuoso';
interface User {
id: string;
name: string;
email: string;
role: string;
status: 'active' | 'inactive';
}
function VirtualTable({ users }: { users: User[] }) {
return (
<TableVirtuoso
style={{ height: 500 }}
data={users}
fixedHeaderContent={() => (
<tr className="bg-gray-100">
<th className="px-4 py-2 text-left">Name</th>
<th className="px-4 py-2 text-left">Email</th>
<th className="px-4 py-2 text-left">Role</th>
<th className="px-4 py-2 text-left">Status</th>
</tr>
)}
itemContent={(index, user) => (
<>
<td className="px-4 py-2 border-b">{user.name}</td>
<td className="px-4 py-2 border-b">{user.email}</td>
<td className="px-4 py-2 border-b">{user.role}</td>
<td className="px-4 py-2 border-b">
<span
className={`px-2 py-1 rounded text-sm ${
user.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100'
}`}
>
{user.status}
</span>
</td>
</>
)}
/>
);
}@tanstack/react-virtual (Headless)
Installation
npm install @tanstack/react-virtualCustom Implementation
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
interface Item {
id: string;
name: string;
description: string;
}
function HeadlessList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Estimated row height
overscan: 5, // Buffer items
});
return (
<div
ref={parentRef}
className="h-[600px] overflow-auto"
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index];
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
className="p-4 border-b"
>
<h3 className="font-medium">{item.name}</h3>
<p className="text-gray-600">{item.description}</p>
</div>
);
})}
</div>
</div>
);
}Dynamic Measurement
import { useVirtualizer } from '@tanstack/react-virtual';
function DynamicHeightList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
measureElement: (element) => element.getBoundingClientRect().height,
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* Content with dynamic height */}
</div>
))}
</div>
</div>
);
}Performance Tips
1. Memoize Row Components
// ALWAYS use memo for row components
const Row = memo(function Row({ data, index, style }) {
// ...
});2. Memoize itemData
// Prevent itemData recreation on every render
const itemData = useMemo(
() => ({ items, onSelect, selectedId }),
[items, onSelect, selectedId]
);3. Avoid Inline Functions
// BAD - new function every render
<FixedSizeList>
{({ index, style }) => <Row index={index} style={style} onClick={() => select(index)} />}
</FixedSizeList>
// GOOD - stable reference
const Row = memo(function Row({ index, style, data }) {
return <div onClick={() => data.onSelect(index)}>...</div>;
});4. Use CSS for Styling
// BAD - inline style object (creates new object)
<div style={{ color: 'red', padding: '10px' }}>
// GOOD - className or CSS modules
<div className="text-red-500 p-2">5. Debounce Window Resize
function ResponsiveList() {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateDimensions = debounce(() => {
setDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
}, 100);
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, []);
return <FixedSizeList width={dimensions.width} height={dimensions.height} />;
}