Skip to content

React · Rendering and Performance

React useMemo and useCallback Patterns — A Complete Guide

7 min read Updated 2026-06-24 Share:

Practice useMemo and useCallback Patterns interview questions

Why interviewers ask about useMemo and useCallback

useMemo and useCallback are React's two memoisation hooks, and they come up in almost every mid-to-senior React interview because they sit at the intersection of performance, correctness, and idiomatic code style. Getting them wrong in both directions — over-using them and under-using them — signals a shallow understanding of the rendering model.

The goal of this guide is to give you a mental model that lets you answer not just "what does it do?" but "when do I reach for it and why?"

useMemo vs useCallback — the core distinction

Both hooks cache a value between renders and invalidate it when dependencies change. The only difference is what they cache:

  • useMemo caches a computed value (an object, array, number, string…)
  • useCallback caches a function definition (its reference)

useCallback(fn, deps) is exactly equivalent to useMemo(() => fn, deps). React ships useCallback as a convenience because caching functions is such a common need.

// These are identical in behaviour:
const memoFn = useCallback(() => doWork(id), [id])
const memoFn = useMemo(() => () => doWork(id), [id])

// useMemo for a value
const sortedItems = useMemo(
  () => [...items].sort(byDate),
  [items]
)

Dependency arrays — the engine of memoisation

The second argument to both hooks is the dependency array. After every render, React compares each element to its previous value with Object.is. If any element changed, the cached result is thrown away and the factory function re-runs.

Three common mistakes:

  1. Empty array [] — the factory runs once and is never invalidated. Fine for truly static values; causes stale data bugs if any closed-over value can change.
  2. No array — the factory re-runs on every render, which defeats the purpose entirely.
  3. Object or array as a dependency — objects are compared by reference, so an inline { limit: 10 } is a "new" dependency on every render even if its contents are identical.

The eslint-plugin-react-hooks package enforces the exhaustive-deps rule, which flags any reactive value used inside the factory but missing from the array. Always run this lint rule — it catches the majority of stale closure bugs automatically.

Referential stability — why it matters downstream

JavaScript compares objects and functions by reference, not by value. Two objects with identical contents are not equal under Object.is:

Object.is({ a: 1 }, { a: 1 }) // false — different references
Object.is([1, 2], [1, 2])     // false — different references

This matters because React.memo, useEffect, and nested useMemo/ useCallback hooks all use Object.is for their comparisons. If an object or function prop gets a new reference on every render, every downstream optimisation is silently broken.

function Parent({ userId }) {
  // New reference every render — MemoizedChart always re-renders
  const options = { userId, theme: 'dark' }

  // Stable reference — MemoizedChart only re-renders when userId changes
  const options = useMemo(() => ({ userId, theme: 'dark' }), [userId])

  return <MemoizedChart options={options} />
}

The rule is simple: before passing an object or function to a React.memo component or into a useEffect dependency list, ensure its reference is stable.

When NOT to memoize

Memoisation has a real cost: React must store the previous value, run the dependency comparison after every render, and keep a closure alive in memory. For cheap operations, this overhead can exceed the saving.

Skip useMemo and useCallback when:

  • The computation is fast (arithmetic, string formatting, simple array ops on small lists)
  • The dependency changes on every render anyway — memoisation never fires
  • The child receiving the callback is not wrapped in React.memo
  • You're in early development — premature optimisation obscures intent
// Unnecessary — wrapping trivial work
const label = useMemo(() => `Hello, ${name}`, [name])
const label = `Hello, ${name}` // same cost, clearer code

// Unnecessary — the button doesn't care about reference stability
const handleClick = useCallback(() => setOpen(true), [])
const handleClick = () => setOpen(true) // fine for DOM elements

Profile with React DevTools Profiler before optimising. Add memoisation only where you can measure a render-time improvement.

Memoizing expensive computations

A computation is worth memoizing when it consistently takes more than ~1 ms on mid-range hardware. You can check with console.time:

console.time('filter')
const result = largeDataset.filter(complexPredicate)
console.timeEnd('filter') // e.g. "filter: 4.2 ms" → worth memoizing

// Production pattern
function ReportTable({ rawRows, activeFilter }) {
  const rows = useMemo(
    () => rawRows
      .filter(r => matchesFilter(r, activeFilter))
      .sort(bySeverity),
    [rawRows, activeFilter]
  )

  return <Table rows={rows} />
}

Common genuinely expensive operations: sorting/filtering large arrays (10 000+ items), heavy string processing, recursive tree traversals. Common cheap operations that don't need memoising: .map over small arrays, simple string concatenation, arithmetic.

Combining useMemo and useCallback with React.memo

React.memo, useMemo, and useCallback form a three-part system. React.memo is the gate; the other two keep the keys from changing unnecessarily.

const DataGrid = React.memo(function DataGrid({ rows, onRowClick }) {
  console.log('DataGrid render')
  return rows.map(row => (
    <Row key={row.id} data={row} onClick={onRowClick} />
  ))
})

function Dashboard({ rawData, userId }) {
  // Stable computed rows — only changes when rawData changes
  const rows = useMemo(
    () => rawData.map(transformRow),
    [rawData]
  )

  // Stable handler — only changes when userId changes
  const handleRowClick = useCallback(
    (rowId) => navigateToDetail(userId, rowId),
    [userId]
  )

  return <DataGrid rows={rows} onRowClick={handleRowClick} />
}

Without useMemo and useCallback above, DataGrid's React.memo wrapper would be a no-op — it would see "new" rows and onRowClick on every Dashboard render regardless of whether the actual data changed.

Stale closures — the hidden correctness bug

A stale closure occurs when a memoized function closes over a value that has since changed because that value was omitted from the dependency array.

function Timer() {
  const [count, setCount] = useState(0)

  // BUG: count is captured at 0 and never updated
  const logCount = useCallback(() => {
    console.log(count) // always prints 0
  }, []) // ← missing `count`

  // FIX: add count to deps
  const logCount = useCallback(() => {
    console.log(count)
  }, [count])

  // ALTERNATIVE FIX for state setters: use functional update
  const increment = useCallback(() => {
    setCount(prev => prev + 1) // reads latest state, no dep needed
  }, [])
}

The eslint-plugin-react-hooks exhaustive-deps rule catches most stale closures. Never omit a dependency to preserve a stable reference — instead, use functional state updates or the ref-callback pattern to legitimately remove the dependency.

Practical patterns

Context provider memoisation — wrap the context value in useMemo to prevent all consumers re-rendering on every provider render:

function AuthProvider({ children }) {
  const [user, setUser] = useState(null)
  const value = useMemo(() => ({ user, setUser }), [user])
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

Custom hooks — always wrap returned functions in useCallback so consumers can safely list them as useEffect dependencies:

function useSearch(query) {
  const search = useCallback(
    () => fetchResults(query),
    [query]
  )
  return { search }
}

Object dependencies — depend on primitives, not objects, to avoid memoisation that never fires:

// Bad — options is a new reference every render
const result = useMemo(() => compute(options), [options])

// Good — depend on the primitives inside
const result = useMemo(() => compute({ limit, offset }), [limit, offset])

Memoisation is a scalpel, not a bandage. Apply it precisely where profiling shows a real cost, and the rest of your component tree stays simple and fast.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel