Back to Blog
React.jsJanuary 9, 20265 min read

React Performance Patterns You Actually Need in 2025

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.

ReactPerformanceNext.jsTypeScriptWeb Development

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.

First: Measure, Don't Guess

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:

  • LCP (Largest Contentful Paint) — perceived load speed
  • INP (Interaction to Next Paint) — responsiveness under load
  • Total JS bundle size — via next/bundle-analyzer
  • API p95 latency — measured at the database query level

Without baselines, optimization is guesswork.

Pattern 1: Virtualize Long Lists

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.

TSX
import { 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.

Pattern 2: Concurrent Features — Deferred Updates

React 18's useTransition lets you mark state updates as non-urgent. The browser stays responsive while React processes them in the background:

TSX
function 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.

Pattern 3: Server Components for Data Fetching

In Next.js App Router, the biggest win is moving data fetching out of useEffect chains and into Server Components:

Before (Client Component + useEffect):

TSX
CE9178">'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:

  • Eliminates the loading spinner waterfall
  • Reduces client-side JavaScript
  • Moves the database call off the network path

Pattern 4: Selective Hydration with Suspense

Not every part of your page needs to be interactive immediately. Wrap less-critical interactive sections in Suspense to defer their hydration:

TSX
import { 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.

Pattern 5: When to Actually Use memo()

React.memo() only prevents re-renders when props don't change. It's useless — and adds overhead — when:

  • The parent always creates new object/function references
  • The component is cheap to render anyway

It's genuinely useful when:

  • The component has expensive render logic (charts, complex DOM)
  • Props are stable (primitives or memoized values)
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}
/>

Pattern 6: Code-Split at the Route Level

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.

Real-World Results

After applying all six patterns to the ReviewX dashboard:

MetricBeforeAfterChange
LCP4.2s1.8s-57%
INP280ms65ms-77%
Bundle size420KB241KB-43%
10K row scroll FPS1260+400%

The biggest wins were virtualization (scroll) and Server Components (LCP). memo() and useMemo() were responsible for maybe 5% of the total improvement.

Conclusion

The most impactful React performance work in 2025 is:

  1. Virtualize lists over ~100 items
  2. Move data fetching to the server — eliminate useEffect waterfalls
  3. Use Suspense boundaries to prioritize hydration order
  4. Code-split lazily-needed features (editors, modals, charts)
  5. Apply useTransition for expensive filter/search updates
  6. Reach for memo() last — measure first, then optimize

The 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.

Similar Posts