Beyond memo() and useMemo() — the real-world React performance patterns that made a measurable difference in a 10,000-user dashboard: concurrent features, virtualization, selective hydration, and measuring what matters.
Performance optimization is full of cargo-culting. Developers wrap everything in React.memo() and useMemo() because they read it makes things faster — then wonder why their app is still slow.
This post is about the patterns that actually moved the needle when I rebuilt the ReviewX dashboard — a React SPA used by 10,000+ WooCommerce store owners to manage customer reviews.
Before touching a single line of code, establish baselines:
Bash# Lighthouse CI in your pipeline npm install -g @lhci/cli lhci autorun --config=lighthouserc.json
The metrics I track for every major release:
next/bundle-analyzerWithout baselines, optimization is guesswork.
The ReviewX dashboard shows tables of 1,000–50,000 reviews. Rendering all DOM nodes killed scroll performance on mid-range devices.
The fix: virtualise the list — render only the visible rows.
TSXimport { useVirtualizer } from CE9178">'@tanstack/react-virtual' function ReviewTable({ reviews }: { reviews: Review[] }) { const parentRef = useRef<HTMLDivElement>(null) const virtualizer = useVirtualizer({ count: reviews.length, getScrollElement: () => parentRef.current, estimateSize: () => 56, // row height in px overscan: 5, }) return ( <div ref={parentRef} className="h-[600px] overflow-auto"> <div style={{ height: virtualizer.getTotalSize() }}> {virtualizer.getVirtualItems().map((item) => ( <div key={item.index} style={{ position: CE9178">'absolute', top: item.start, height: item.size, }} > <ReviewRow review={reviews[item.index]} /> </div> ))} </div> </div> ) }
Result: scroll performance went from 12 FPS to a consistent 60 FPS on a 2019 MacBook Air.
React 18's useTransition lets you mark state updates as non-urgent. The browser stays responsive while React processes them in the background:
TSXfunction ReviewSearch() { const [query, setQuery] = useState(CE9178">'') const [results, setResults] = useState<Review[]>([]) const [isPending, startTransition] = useTransition() function handleSearch(value: string) { setQuery(value) // Urgent — update the input immediately startTransition(() => { // Non-urgent — can be interrupted by user input setResults(filterReviews(allReviews, value)) }) } return ( <> <input value={query} onChange={(e) => handleSearch(e.target.value)} /> {isPending ? <Spinner /> : <ReviewList reviews={results} />} </> ) }
The input now feels instant even when filtering 10,000 records — because React interrupts the expensive filter work if the user types again.
In Next.js App Router, the biggest win is moving data fetching out of useEffect chains and into Server Components:
Before (Client Component + useEffect):
TSXCE9178">'use client' function Dashboard() { const [stats, setStats] = useState(null) useEffect(() => { fetch(CE9178">'/api/stats').then(r => r.json()).then(setStats) }, []) if (!stats) return <Spinner /> return <StatsGrid stats={stats} /> }
After (Server Component):
TSX// No CE9178">'use client' — this runs on the server async function Dashboard() { const stats = await getStats() // Direct DB call, no API round-trip return <StatsGrid stats={stats} /> }
The Server Component version:
Not every part of your page needs to be interactive immediately. Wrap less-critical interactive sections in Suspense to defer their hydration:
TSXimport { Suspense, lazy } from CE9178">'react' const AnalyticsChart = lazy(() => import(CE9178">'./AnalyticsChart')) const CommentThread = lazy(() => import(CE9178">'./CommentThread')) function ReviewDetail({ review }: { review: Review }) { return ( <div> {/* Hydrates immediately — user needs this */} <ReviewActions review={review} /> {/* Hydrates when idle — less urgent */} <Suspense fallback={<ChartSkeleton />}> <AnalyticsChart reviewId={review.id} /> </Suspense> <Suspense fallback={<CommentSkeleton />}> <CommentThread reviewId={review.id} /> </Suspense> </div> ) }
This pattern is especially powerful on slow connections — the critical UI (the review actions) becomes interactive immediately while secondary content hydrates in background.
React.memo() only prevents re-renders when props don't change. It's useless — and adds overhead — when:
It's genuinely useful when:
TSX// Expensive chart component — memo makes sense const ReviewTrendChart = memo(function ReviewTrendChart({ data, period }: { data: TrendData[]; period: string }) { // Heavy D3 computation here return <canvas ref={canvasRef} /> }) // Only re-render when data or period actually changes <ReviewTrendChart data={useMemo(() => processData(rawData), [rawData])} period={selectedPeriod} />
Ship only the JavaScript for the current page. Next.js does this automatically per route, but don't forget dynamic imports for heavy libraries:
TSX// Don't import heavy editors globally const MarkdownEditor = dynamic(() => import(CE9178">'@/components/MarkdownEditor'), { loading: () => <EditorSkeleton />, ssr: false, // Editor doesn't need SSR })
In ReviewX, the response editor (with Markdown support and emoji picker) went from being in the main bundle to loading on demand. That's 180KB off the initial load.
After applying all six patterns to the ReviewX dashboard:
| Metric | Before | After | Change |
|---|---|---|---|
| LCP | 4.2s | 1.8s | -57% |
| INP | 280ms | 65ms | -77% |
| Bundle size | 420KB | 241KB | -43% |
| 10K row scroll FPS | 12 | 60 | +400% |
The biggest wins were virtualization (scroll) and Server Components (LCP). memo() and useMemo() were responsible for maybe 5% of the total improvement.
The most impactful React performance work in 2025 is:
useTransition for expensive filter/search updatesmemo() last — measure first, then optimizeThe pattern that surprised me most was how much Server Components moved the needle on perceived performance. Moving database calls off the client eliminates an entire round-trip that you've just been accepting as table stakes.