React Optimization
| Field | Value |
|---|---|
| Type | Agent Reference |
| Source | ~/.copilot/agents/_refs/react-perf-auditor/react-optimization.md |
| Description | Not specified |
Source Content
React Optimization
react-scan — Render Diagnostics
react-scan highlights components that re-render unnecessarily. Always profile before optimizing.
pnpm add -D react-scan// src/main.tsx — dev onlyif (import.meta.env.DEV) { const { scan } = await import('react-scan') scan({ enabled: true, log: true })}Open the app, interact normally, and watch for red highlights. Components that flash frequently are re-rendering on every interaction.
Reading the output:
- Red flash = the component just re-rendered
- Intense red = rendering many times per second (problematic)
- Number in corner = total render count since scan started
Re-render Root Causes
Before reaching for memo, identify WHY the component is re-rendering:
- Parent re-renders — most common. The parent changed state → all children re-render.
- New object/array reference —
{ a: 1 }!=={ a: 1 }in JS. Props that are objects created inline cause re-renders every time. - Context value changes — every consumer re-renders when context value changes.
- Selector misuse —
const store = useAppStore()subscribes to the entire Zustand store.
// BAD — new object reference on every parent renderfunction Parent() { const [count, setCount] = useState(0) return <Child config={{ theme: 'light' }} /> // new object every render}
// GOOD — stable reference (or move outside component if truly static)const LIGHT_CONFIG = { theme: 'light' }function Parent() { return <Child config={LIGHT_CONFIG} />}
// BAD — subscribes to entire store (re-renders on any store change)const store = useAppStore()const isCollapsed = store.isCollapsed
// GOOD — targeted selectorconst isCollapsed = useAppStore((s) => s.isCollapsed)React 19 — Let the Compiler Work
React 19 ships with React Compiler. It automatically memoizes components, values, and callbacks where safe. Do NOT reach for React.memo, useMemo, or useCallback as a first resort.
Only add manual memoization when:
- Profiling shows a measurable problem (react-scan confirms excessive renders)
- The component genuinely does expensive computation
- The React Compiler cannot optimize it (rare — usually requires passing callbacks across context boundaries)
// Only memoize when profiling proves it mattersconst expensiveResult = useMemo( () => massiveDataSet.filter(complexPredicate).map(transform), [massiveDataSet, complexPredicate] // stable dependencies)
// forwardRef is gone in React 19 — pass ref as a prop directlyfunction Input({ ref, ...props }: React.ComponentProps<'input'>) { return <input ref={ref} {...props} />}Concurrent React Features
// useDeferredValue — defer expensive renders triggered by fast inputfunction SearchResults({ query }: { query: string }) { const deferredQuery = useDeferredValue(query) const isStale = query !== deferredQuery
return ( <div style={{ opacity: isStale ? 0.5 : 1 }}> <HeavyResultList query={deferredQuery} /> </div> )}
// startTransition — mark non-urgent state updatesimport { startTransition } from 'react'
function handleTabChange(tab: string) { // Urgent: update the selected tab visually setSelectedTab(tab) // Non-urgent: update the content (can be interrupted) startTransition(() => { setTabContent(loadTabContent(tab)) })}
// <Activity> (React 19.2) — preserve state for hidden UIimport { Activity } from 'react'
function TabPanel({ activeTab }: { activeTab: string }) { return ( <> <Activity mode={activeTab === 'overview' ? 'visible' : 'hidden'}> <OverviewTab /> </Activity> <Activity mode={activeTab === 'documents' ? 'visible' : 'hidden'}> <DocumentsTab /> </Activity> </> )}List Virtualization
Never render a list of 100+ items without virtualization. Use TanStack Virtual:
pnpm add @tanstack/react-virtualimport { useVirtualizer } from '@tanstack/react-virtual'import { useRef } from 'react'
function CaseList({ cases }: { cases: Case[] }) { const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({ count: cases.length, getScrollElement: () => parentRef.current, estimateSize: () => 72, // estimated row height in px overscan: 5, // render 5 extra items above/below viewport })
return ( <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}> <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}> {virtualizer.getVirtualItems().map((virtualItem) => ( <div key={virtualItem.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)`, }} > <CaseRow case={cases[virtualItem.index]} /> </div> ))} </div> </div> )}Code Splitting
Route-level splitting is automatic with file-based routing (TanStack Router, Astro). For manual splitting of heavy components:
import { lazy, Suspense } from 'react'
// Only load the chart library when the chart is actually renderedconst ActivityChart = lazy(() => import('./ActivityChart'))const DataGrid = lazy(() => import('./DataGrid'))
function Dashboard() { return ( <Suspense fallback={<Skeleton className="h-64 w-full" />}> <ActivityChart data={chartData} /> </Suspense> )}Use lazy loading for:
- Heavy visualization libraries (charts, maps, editors)
- Components that appear only on user interaction (dialogs, modals that open rarely)
- Components below the fold on the initial view
Do NOT lazy-load components that are always visible on page load — it causes worse LCP.
TanStack Query Performance
// Use staleTime to avoid unnecessary refetchesconst { data } = useQuery({ queryKey: ['cases', filters], queryFn: () => fetchCases(filters), staleTime: 5 * 60 * 1000, // data is fresh for 5 min — no background refetch gcTime: 10 * 60 * 1000, // cache survives for 10 min after last consumer unmounts})
// Prevent layout shift during paginationimport { keepPreviousData } from '@tanstack/react-query'
const { data, isFetching } = useQuery({ queryKey: ['cases', { page }], queryFn: () => fetchCasesPage(page), placeholderData: keepPreviousData, // show old data while new page loads})
// Parallel queries — fetch multiple resources simultaneouslyconst [cases, clients] = useQueries({ queries: [ { queryKey: ['cases'], queryFn: fetchCases }, { queryKey: ['clients'], queryFn: fetchClients }, ],})Image Optimization
<!-- Astro Image component — automatic WebP conversion, lazy loading, aspect ratio -->---import { Image } from 'astro:assets'import heroImage from '../assets/hero.jpg'---
<!-- Above-the-fold: eager + high priority --><Image src={heroImage} alt="Courtroom" width={1440} height={640} loading="eager" fetchpriority="high" format="webp"/>
<!-- Below-the-fold: lazy --><Image src={heroImage} alt="Courtroom" width={800} height={400} loading="lazy" format="webp"/>For React islands, use native loading="lazy" + explicit dimensions to prevent CLS:
<img src="/photo.webp" alt="..." width={800} height={600} loading="lazy" decoding="async" style={{ aspectRatio: '800/600' }} // prevents CLS even before image loads/>