Skip to content

React Optimization

FieldValue
TypeAgent Reference
Source~/.copilot/agents/_refs/react-perf-auditor/react-optimization.md
DescriptionNot specified

Source Content

React Optimization

react-scan — Render Diagnostics

react-scan highlights components that re-render unnecessarily. Always profile before optimizing.

Terminal window
pnpm add -D react-scan
// src/main.tsx — dev only
if (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:

  1. Parent re-renders — most common. The parent changed state → all children re-render.
  2. New object/array reference{ a: 1 } !== { a: 1 } in JS. Props that are objects created inline cause re-renders every time.
  3. Context value changes — every consumer re-renders when context value changes.
  4. Selector misuseconst store = useAppStore() subscribes to the entire Zustand store.
// BAD — new object reference on every parent render
function 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 selector
const 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:

  1. Profiling shows a measurable problem (react-scan confirms excessive renders)
  2. The component genuinely does expensive computation
  3. The React Compiler cannot optimize it (rare — usually requires passing callbacks across context boundaries)
// Only memoize when profiling proves it matters
const expensiveResult = useMemo(
() => massiveDataSet.filter(complexPredicate).map(transform),
[massiveDataSet, complexPredicate] // stable dependencies
)
// forwardRef is gone in React 19 — pass ref as a prop directly
function Input({ ref, ...props }: React.ComponentProps<'input'>) {
return <input ref={ref} {...props} />
}

Concurrent React Features

// useDeferredValue — defer expensive renders triggered by fast input
function 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 updates
import { 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 UI
import { 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:

Terminal window
pnpm add @tanstack/react-virtual
import { 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 rendered
const 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 refetches
const { 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 pagination
import { 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 simultaneously
const [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
/>