useRef, useMemo & useCallback
The three hooks that don't cause re-renders: useRef for mutable values and DOM access, useMemo for caching expensive values, and useCallback for stable function identity — plus when memoization actually pays off and when it's just noise.
One family, three jobs (start here). useRef, useMemo, and useCallback share a theme: they let a component remember something across renders without triggering a re-render themselves. useRef remembers a mutable box; useMemo remembers a computed value; useCallback remembers a function. Understanding when each pays off — and when it's pointless ceremony — is a common interview differentiator.
useRef is a mutable box that survives renders. useRef(initial) returns an object { current: initial } that persists for the component's whole life. Two properties make it special: mutating ref.current does not cause a re-render, and the same object identity is preserved across every render. Use it for values that must outlive a render but shouldn't drive the UI: a DOM node, a timer id, the previous value of a prop, a mutable instance flag, or a cancelled guard.
The most common ref use: reaching a DOM node. Attach a ref to an element via the ref prop, and after commit ref.current points at the real DOM node so you can call imperative APIs React doesn't model declaratively — .focus(), .scrollIntoView(), .play(), measuring getBoundingClientRect(). Guard with optional chaining (inputRef.current?.focus()) because the ref is null before the element mounts and after it unmounts.
Ref vs state — the decision rule. Both persist across renders, but state updates re-render and are shown in the UI; ref mutations are silent and are not. If changing the value should update what the user sees, use state. If the value is bookkeeping the render doesn't depend on (interval ids, the latest callback, a 'did we already run this' flag), use a ref. A useful tell: if you find yourself writing ref.current and reading it in JSX to display it, you probably wanted state.
useMemo caches a computed value. useMemo(() => compute(a, b), [a, b]) runs compute on the first render, caches the result, and returns the cached value on later renders until a dependency changes (compared with Object.is). It buys you two distinct things: (1) skipping genuinely expensive work (a large sort/filter/reduce) on every render, and (2) referential stability — returning the same object/array reference so a memoized child or an effect's deps array doesn't see a 'new' value every render.
useCallback caches a function's identity. useCallback(fn, deps) is exactly useMemo(() => fn, deps) — it returns the same function reference until a dependency changes. Functions are recreated on every render, so passing an inline handler to a React.memo child, or listing it in an effect's deps, defeats memoization / re-runs the effect. useCallback stabilizes that identity so the optimization holds. On its own, wrapping a handler passed only to a plain DOM element does nothing useful.
Memoization is not free — the cost/benefit rule. useMemo/useCallback add memory (storing the cached value and deps) and a comparison on every render. For a cheap computation like a + b or items.length, that overhead exceeds the savings — don't memoize it. Reach for them when the computation is measurably expensive, or when a value/function needs a stable identity to make a downstream optimization (React.memo, an effect dep) actually work. Memoizing 'just in case' clutters code and can even slow things slightly.
Referential identity is the real reason most memos exist. In React, {} !== {} and () => {} !== () => {}. A parent that re-renders passes brand-new object and function props to its children every time; a React.memo child then re-renders anyway because its props changed by reference. Wrapping those props in useMemo/useCallback keeps their identity stable so React.memo can actually bail out. This identity concern is also why inline objects/functions in dependency arrays cause effects to re-run — the same root cause.
Refs for the latest value (advanced pattern). A common way to avoid stale closures without re-subscribing an effect is a 'latest ref': keep ref.current updated to the newest callback/value in a tiny effect, and read ref.current inside a long-lived subscription. The subscription stays stable (empty deps) yet always calls the freshest function. This is the manual version of what useEffectEvent (experimental) aims to formalize.
Common gotchas. Don't read or write ref.current during render — mutations belong in effects or event handlers, because render must be pure. Don't overuse useMemo/useCallback — measure first. Remember useMemo may still recompute: React can discard the cache under memory pressure, so never rely on it for correctness (side effects belong in effects, not memos). And useRef's initial argument is only used on the first render — passing a fresh new Foo() each render still allocates it every render even though it's ignored (use lazy init if that matters).
The mental model (memorise this). Three hooks, zero re-renders: useRef = a mutable box whose changes are invisible to rendering (DOM nodes, timer ids, latest-value stash); useMemo = cache this value, recompute only when deps change; useCallback = cache this function's identity. Use state, not a ref, when the UI depends on the value. Memoize only for expensive work or to preserve referential identity that a downstream React.memo or effect dep relies on — otherwise it's just noise.
useRef is like a private mutable field on a singleton bean - it persists across method calls (renders) and mutating it doesn't trigger any framework lifecycle, unlike a state field that fires change events. useMemo is @Cacheable: cache the result of an expensive method keyed by its arguments (the deps) and recompute only on a key change; it's advisory, so like a cache that may be evicted, you never depend on it for correctness. useCallback is memoizing a lambda/functional interface so downstream consumers keep the same reference - the same reason you'd hoist a Comparator or Predicate to a static final instead of allocating a new one on every call, so equality-based caching downstream keeps working.
- useRef, useMemo, and useCallback all persist a value across renders without triggering a re-render.
- useRef returns a stable { current } box; mutating current is silent and does not re-render, and its identity is preserved.
- Use a ref for DOM nodes, timer ids, previous values, and mutable flags - anything the render output doesn't depend on.
- Use state (not a ref) when changing the value should update the UI; use a ref when the change should be invisible to rendering.
- useMemo caches a computed value and recomputes only when a dependency changes (compared via Object.is).
- useCallback(fn, deps) equals useMemo(() => fn, deps): it stabilizes a function's identity across renders.
- Memoization's biggest use is referential stability so React.memo children and effect deps don't see a new object/function every render.
- Memoization has real cost (memory + comparison); skip it for cheap computations and handlers passed to plain DOM elements.
- In JS {} !== {} and () => {} !== () => {}, which is why unmemoized object/function props defeat React.memo and re-run effects.
- useMemo is advisory - React may discard the cache, so never rely on it for correctness and never put side effects in it.
Worked Code
import { useRef, useEffect, useState } from 'react';
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null); // DOM node
const timerRef = useRef<number | null>(null); // mutable id, survives renders
const focusInput = () => inputRef.current?.focus(); // guard: null before mount
const debouncedLog = (value: string) => {
if (timerRef.current) clearTimeout(timerRef.current); // read + write, no re-render
timerRef.current = window.setTimeout(() => console.log('search:', value), 300);
};
return (
<div>
<input ref={inputRef} onChange={(e) => debouncedLog(e.target.value)} />
<button onClick={focusInput}>Focus search</button>
</div>
);
}
// "previous value" pattern: keep last render's value in a ref
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => { ref.current = value; }, [value]); // update AFTER render
return ref.current; // returns the previous value
}import { useMemo } from 'react';
function ExpenseDashboard({ items, taxRate }: { items: Expense[]; taxRate: number }) {
// (1) Skip expensive work: only re-sort when items change, not when taxRate does.
const sorted = useMemo(
() => [...items].sort((a, b) => b.amount - a.amount), // O(n log n)
[items]
);
// (2) Stable identity: this object is passed to a memoized child, so keep the
// SAME reference unless a dependency actually changes.
const summary = useMemo(
() => ({ total: items.reduce((s, e) => s + e.amount, 0), taxRate }),
[items, taxRate]
);
// DON'T memoize trivial work - the overhead outweighs the saving:
const count = items.length; // just compute inline, no useMemo
return <SummaryCard summary={summary} rows={sorted} count={count} />;
}import { useCallback, useState, memo } from 'react';
// Child only re-renders when its props change BY REFERENCE.
const Row = memo(function Row({ id, onSelect }: { id: string; onSelect: (id: string) => void }) {
console.log('render row', id);
return <li onClick={() => onSelect(id)}>{id}</li>;
});
function List({ ids }: { ids: string[] }) {
const [selected, setSelected] = useState<string | null>(null);
// Without useCallback, onSelect is a NEW function each render, so every memoized
// Row re-renders even when only 'selected' changed. useCallback stabilizes it.
const onSelect = useCallback((id: string) => setSelected(id), []); // stable identity
return (
<ul>
{ids.map((id) => <Row key={id} id={id} onSelect={onSelect} />)}
<p>Selected: {selected ?? 'none'}</p>
</ul>
);
}import { useRef, useEffect } from 'react';
// A subscription that stays stable (empty deps) yet always calls the FRESHEST cb.
function useInterval(callback: () => void, delayMs: number) {
const savedCallback = useRef(callback);
// Keep the ref pointing at the latest callback after every render.
useEffect(() => { savedCallback.current = callback; }, [callback]);
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delayMs); // reads latest
return () => clearInterval(id);
}, [delayMs]); // re-subscribe only when the delay changes, never for callback
}▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
Both persist a value across renders, but mutating ref.current does NOT trigger a re-render and isn't reflected in the UI, whereas setState re-renders and updates the UI. Use state for values the render output depends on; use a ref for bookkeeping the UI doesn't display - DOM node references, timer ids, previous values, or mutable instance flags.
- 1useRef, useMemo, useCallback: persist across renders without re-rendering.
- 2Mutating ref.current is silent and never re-renders; setState does both.
- 3Use state when the UI depends on the value; use a ref when it shouldn't.
- 4useRef is the escape hatch for DOM APIs: focus, scroll, measure, media.
- 5useMemo caches a value; useCallback caches a function's identity.
- 6useCallback(fn, deps) === useMemo(() => fn, deps).
- 7Memoization mainly buys referential stability for React.memo and effect deps.
- 8Don't memoize cheap work or handlers passed only to plain DOM elements.
- 9{} !== {} and () => {} !== () => {} - the root of most re-render surprises.
- 10useMemo is advisory: keep it pure, never rely on it for correctness.