Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsPerformanceMemoizationOptimization

React Performance Guide (Part 1)

Optimize React renders — re-render triggers, React.memo, useCallback, useMemo, derived state, debouncing and throttling hooks, and a preview of virtualization, Context splits, and React Compiler.

Jul 5, 202614 min read

Introduction

React re-renders are cheap until they are not. Large lists, unstable props, derived state stored in useEffect, and search inputs firing API calls on every keystroke are where performance problems appear. This Performance Guide (Part 1) maps the foundational patterns: understand what triggers renders, apply memoization deliberately, compute derived state in render, and debounce/throttle high-frequency handlers.

This guide covers:

  1. Re-rendering triggers — state, props, Context, parent renders, Strict Mode
  2. Memoization — React.memo, useCallback, useMemo
  3. Derived state patterns — compute in render, avoid sync effects
  4. Debouncing — useDebounce for search and API calls
  5. Throttling — rate-limit scroll and resize handlers
  6. Advanced techniques (Part 2 preview) — splitting, virtualization, Context optimization, React Compiler

Note

Performance work starts with measurement, not memoization. Profile with React DevTools Profiler before wrapping components in memo — premature optimization adds complexity without measurable gain.

Quick index

#Section
1Re-rendering triggers
2Memoization
3Derived state patterns
4Debouncing
5Throttling
6Advanced techniques (Part 2 preview)
7Best practices & checklist

1. Re-rendering triggers

A component re-renders when React detects that its output might have changed. Know the triggers before optimizing.

TriggerWhat happens
State changesuseState, useReducer dispatch updates local state
Prop changesParent passes new prop values (reference or primitive)
Context value changesAny consumer re-renders when Provider value changes
Parent re-rendersDefault: child re-renders even if its props are identical
Strict Mode (dev only)React double-invokes renders to surface impure side effects
plaintext
Parent state changes
  → Parent re-renders
  → All children re-render (unless memo + stable props)
  → Context consumers re-render if value reference changed

Minimal reproduction

tsx
'use client'
 
import { useState } from 'react'
 
function Child({ label }: { label: string }) {
  console.log('Child render:', label)
  return <p>{label}</p>
}
 
function Parent() {
  const [count, setCount] = useState(0)
 
  return (
    <div>
      <button type="button" onClick={() => setCount((c) => c + 1)}>
        Count: {count}
      </button>
      {/* Child re-renders on every count click — props unchanged */}
      <Child label="Static" />
    </div>
  )
}

Note

Strict Mode double-rendering in development is intentional — it does not happen in production builds. Do not "fix" it by removing Strict Mode; fix impure render logic instead.

Tip

When a child re-renders unexpectedly, ask: did props change reference? ({}, [], inline functions) or did a Context ancestor update? — those are the two most common surprises.

Back to index


2. Memoization

Memoization caches results so React skips redundant work. Three tools cover components, functions, and values.

ToolCachesSkips when
React.memoComponent outputProps are shallow-equal to previous render
useCallbackFunction referenceDependencies unchanged
useMemoComputed valueDependencies unchanged

React.memo — HOC for components

memo is a higher-order function — it wraps a component and bails out when props are unchanged.

tsx
import { memo } from 'react'
 
type RowProps = { label: string; value: number }
 
const ExpensiveRow = memo(function ExpensiveRow({ label, value }: RowProps) {
  return (
    <tr>
      <td>{label}</td>
      <td>{value}</td>
    </tr>
  )
})
 
function DataTable({ rows }: { rows: RowProps[] }) {
  return (
    <table>
      <tbody>
        {rows.map((r) => (
          <ExpensiveRow key={r.label} {...r} />
        ))}
      </tbody>
    </table>
  )
}

useCallback — stable function references

Inline handlers create a new function every render, breaking memo on children.

tsx
'use client'
 
import { memo, useCallback, useState } from 'react'
 
const SearchResults = memo(function SearchResults({
  query,
  onSelect,
}: {
  query: string
  onSelect: (id: string) => void
}) {
  // expensive filter…
  return (
    <ul>
      <li>
        <button type="button" onClick={() => onSelect('1')}>
          Result for {query}
        </button>
      </li>
    </ul>
  )
})
 
function SearchPage() {
  const [query, setQuery] = useState('')
 
  const handleSelect = useCallback((id: string) => {
    console.log('Selected', id)
  }, [])
 
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <SearchResults query={query} onSelect={handleSelect} />
    </>
  )
}

useMemo — cache expensive computation

tsx
'use client'
 
import { useMemo, useState } from 'react'
 
type Product = { id: string; name: string; price: number; category: string }
 
function ProductCatalog({ products, filter }: { products: Product[]; filter: string }) {
  const filtered = useMemo(() => {
    const lower = filter.toLowerCase()
    return products.filter((p) => p.name.toLowerCase().includes(lower) || p.category.toLowerCase().includes(lower))
  }, [products, filter])
 
  const totalValue = useMemo(() => filtered.reduce((sum, p) => sum + p.price, 0), [filtered])
 
  return (
    <div>
      <p>
        {filtered.length} products — total ${totalValue}
      </p>
      <ul>
        {filtered.map((p) => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </div>
  )
}

Performance

Avoid useMemo for small calculations — filtering ten items or adding two numbers is faster without memo overhead. Reserve useMemo for O(n²) transforms, large dataset sorts, or referential stability needed by downstream memo children.

Warning

memo compares props shallowly. Passing a new object or array prop every render defeats memoization:

tsx
// ❌ New object every render — ExpensiveRow always re-renders
<ExpensiveRow style={{ color: 'red' }} label="A" value={1} />
 
// ✅ Lift static style out or memoize the object
const style = useMemo(() => ({ color: 'red' }), [])
<ExpensiveRow style={style} label="A" value={1} />

Back to index


3. Derived state patterns

Derived state is a value computed from existing state or props — it should not live in its own useState synced via useEffect.

ApproachVerdict
Store derived value in useState❌ Anti-pattern — duplicate source of truth
Sync with useEffect❌ Extra render cycle, easy to desync
Compute in render✅ Single source of truth, always consistent
useMemo for expensive derive✅ When computation cost justifies caching

Anti-pattern — derived state in useState + useEffect

tsx
'use client'
 
import { useEffect, useState } from 'react'
 
function OrderSummary({ items }: { items: { price: number; qty: number }[] }) {
  const [total, setTotal] = useState(0)
 
  // ❌ Sync effect — runs after paint, can flash wrong value
  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0))
  }, [items])
 
  return <p>Total: ${total}</p>
}

Correct — compute directly in render

tsx
function OrderSummary({ items }: { items: { price: number; qty: number }[] }) {
  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0)
 
  return <p>Total: ${total}</p>
}

Expensive derived value — useMemo

tsx
function UserDashboard({ users, roleFilter }: { users: User[]; roleFilter: string }) {
  const admins = useMemo(
    () => users.filter((u) => u.role === roleFilter).sort((a, b) => a.name.localeCompare(b.name)),
    [users, roleFilter],
  )
 
  return (
    <ul>
      {admins.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  )
}

Note

If you can write const x = f(props) without measurable lag, skip useMemo. Derived state in render is the default; memo is the exception after profiling.

Tip

Full name from first + last — const fullName = \$ $`` in render beats two state fields and a sync effect every time.

Back to index


4. Debouncing

Debouncing delays execution until activity pauses — ideal when the user types faster than the server should respond.

plaintext
keystrokes:  r → re → rea → reac → react
debounced:   ·    ·     ·      ·      ·────► API call (300ms after last key)

useDebounce hook

tsx
'use client'
 
import { useEffect, useState } from 'react'
 
export function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value)
 
  useEffect(() => {
    const timeout = window.setTimeout(() => setDebounced(value), delay)
    return () => window.clearTimeout(timeout)
  }, [value, delay])
 
  return debounced
}

Warning

Always clearTimeout in the effect cleanup — without cleanup, stale timeouts fire after unmount or when value changes rapidly, causing race conditions and duplicate API calls.

Search input with debounced API call

tsx
'use client'
 
import { useEffect, useState } from 'react'
import { useDebounce } from '@/hooks/useDebounce'
 
type SearchResult = { id: string; title: string }
 
async function searchApi(query: string): Promise<SearchResult[]> {
  if (!query) return []
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
  return res.json()
}
 
function BlogSearch() {
  const [input, setInput] = useState('')
  const [results, setResults] = useState<SearchResult[]>([])
  const [loading, setLoading] = useState(false)
 
  const debouncedQuery = useDebounce(input, 300)
 
  useEffect(() => {
    let cancelled = false
    setLoading(true)
 
    searchApi(debouncedQuery)
      .then((data) => {
        if (!cancelled) setResults(data)
      })
      .finally(() => {
        if (!cancelled) setLoading(false)
      })
 
    return () => {
      cancelled = true
    }
  }, [debouncedQuery])
 
  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Search posts…" aria-busy={loading} />
      <ul>
        {results.map((r) => (
          <li key={r.id}>{r.title}</li>
        ))}
      </ul>
    </div>
  )
}

Tip

Debounce 300–500ms for search; 150–200ms for resize-driven layout recalculation. Show the raw input immediately — only debounce the side effect (API, filter), not what the user sees in the text field.

Back to index


5. Throttling

Throttling ensures a function runs at most once per time window — ideal for high-frequency events where you need regular updates, not a final pause.

PatternFires whenBest for
DebounceActivity stopsSearch API, form validation
ThrottleAt most once per intervalScroll, resize, mousemove

useThrottle hook (timestamp-based)

tsx
'use client'
 
import { useEffect, useRef, useState } from 'react'
 
export function useThrottle<T>(value: T, interval: number): T {
  const [throttled, setThrottled] = useState(value)
  const lastRan = useRef(Date.now())
 
  useEffect(() => {
    const now = Date.now()
    const elapsed = now - lastRan.current
 
    if (elapsed >= interval) {
      lastRan.current = now
      setThrottled(value)
      return
    }
 
    const timeout = window.setTimeout(() => {
      lastRan.current = Date.now()
      setThrottled(value)
    }, interval - elapsed)
 
    return () => window.clearTimeout(timeout)
  }, [value, interval])
 
  return throttled
}

Scroll position tracker

tsx
'use client'
 
import { useEffect, useState } from 'react'
 
function useScrollY(throttleMs = 100) {
  const [scrollY, setScrollY] = useState(0)
 
  useEffect(() => {
    let ticking = false
    let lastRan = 0
 
    function onScroll() {
      const now = Date.now()
      if (!ticking) {
        window.requestAnimationFrame(() => {
          if (now - lastRan >= throttleMs) {
            lastRan = now
            setScrollY(window.scrollY)
          }
          ticking = false
        })
        ticking = true
      }
    }
 
    window.addEventListener('scroll', onScroll, { passive: true })
    return () => window.removeEventListener('scroll', onScroll)
  }, [throttleMs])
 
  return scrollY
}
 
function StickyHeader() {
  const scrollY = useScrollY(100)
  const compact = scrollY > 64
 
  return (
    <header className={compact ? 'h-12 shadow' : 'h-20'}>
      <h1 className={compact ? 'text-sm' : 'text-xl'}>My App</h1>
    </header>
  )
}

Performance

Combine requestAnimationFrame with throttling for scroll-driven DOM updates — aligns reads/writes with the browser paint cycle and avoids layout thrashing.

Note

For scroll handlers that only read geometry, prefer passive: true event listeners — browsers can optimize scrolling without waiting for your handler to confirm it will not call preventDefault().

Back to index


6. Advanced techniques (Part 2 preview)

Part 1 covers render discipline; Part 2 (coming next) goes deeper. Preview the four advanced areas now so you know where optimization scales.

TechniqueProblem it solves
Splitting componentsIsolate state so only the changing subtree re-renders
VirtualizationRender only visible rows in 10k+ item lists
Context optimizationPrevent Provider value changes from re-rendering the whole app
React CompilerAutomatic memoization at build time (React 19+)

Splitting components — isolate state

tsx
// ❌ One component — typing re-renders the heavy chart
function Dashboard() {
  const [filter, setFilter] = useState('')
  return (
    <>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />
      <ExpensiveChart filter={filter} />
    </>
  )
}
 
// ✅ Split — input state lives in child; chart sibling skips re-render on unrelated parent updates
function Dashboard() {
  return (
    <>
      <FilterInput />
      <ExpensiveChartWrapper />
    </>
  )
}

Virtualization — @tanstack/react-virtual

tsx
'use client'
 
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null)
 
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
  })
 
  return (
    <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((row) => (
          <div
            key={row.key}
            style={{
              position: 'absolute',
              top: row.start,
              height: row.size,
              width: '100%',
            }}>
            {items[row.index]}
          </div>
        ))}
      </div>
    </div>
  )
}

Context optimization — split providers

tsx
// ❌ One context — theme change re-renders cart consumers
const AppContext = createContext({ theme: 'light', cart: [] })
 
// ✅ Split — theme subscribers ignore cart updates
<ThemeProvider>
  <CartProvider>{children}</CartProvider>
</ThemeProvider>

See Provider Pattern for split state/actions contexts and memoized values.

React Compiler

React 19's React Compiler (formerly React Forget) analyzes components at build time and inserts memoization automatically — reducing manual memo / useMemo / useCallback boilerplate when enabled in your toolchain.

Note

Part 2 will expand splitting strategies, full virtualization patterns, Context selector libraries, and Compiler setup. Apply Part 1 patterns first — the Compiler does not replace understanding re-render triggers.

Tip

Reach for virtualization when lists exceed ~100 DOM nodes with measurable scroll jank — not for ten-item menus.

Back to index


7. Best practices & checklist

MistakeFix
Memo every componentProfile first; memo expensive leaves only
useMemo on trivial mathCompute in render
Derived state in useEffectDerive in render or useMemo
Inline objects/functions to memo childrenuseMemo / useCallback or split components
Debounce without cleanupclearTimeout in effect return
Throttle scroll without passiveAdd { passive: true } to listeners
Context with one fat value objectSplit contexts by update frequency

Interview Answer

"When do you use debounce vs throttle?"

Debounce when you care about the final value after activity stops — search queries, autosave drafts. Throttle when you need regular samples during continuous activity — scroll position, resize layout, progress bar updates. Debounce waits for silence; throttle guarantees a maximum interval between executions.

Tip

Production checklist (Part 1)

  • Identify re-render trigger before optimizing
  • Compute derived state in render; avoid sync effects
  • React.memo + stable props for expensive list rows
  • useCallback when passing handlers to memoized children
  • useMemo only for proven expensive work
  • useDebounce for search/API; cleanup timeouts
  • Throttle scroll/resize; use passive listeners
  • Split components to isolate fast-changing state
  • Virtualize lists over ~100 items
  • Split Context by concern — see Provider Pattern guide

Warning

Do not ship memoization in a hot path without before/after Profiler screenshots — teams often add useMemo everywhere and increase memory use while barely improving Interaction to Next Paint (INP).

Back to index


Summary

LayerPart 1 tool
UnderstandRe-render triggers
Skip workmemo, useCallback, useMemo
Stay correctDerived state in render
Rate-limitDebounce (pause) / throttle (interval)
Scale (Part 2)Split, virtualize, Context, Compiler

Performance is a practice, not a one-time refactor. Measure renders, fix the highest-cost trigger, and reach for advanced techniques only when Part 1 tools are insufficient.

Next reads: Provider Pattern for Context optimization, Optimistic UI Pattern for transition-based updates, and React Design Patterns for the full pattern roadmap.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime