• Understanding stable references before React memoization

    The part I love most about doing a React code review is hunting down memoization that never needed to exist in the first place: the useMemo and useCallback calls. It is one of those things that feels productive to add because it wears the costume of an optimization. My favourite moment is deleting one of these and watching nothing change, because nothing was ever supposed to change, and the hook was only ever paying a cost for a benefit that was never there.

    That is the uncomfortable truth about memoization that gets skipped over. It is not free, and if used carelessly without a clear understanding of what it does and what problem it exists to solve, it can quietly make your app’s performance worse rather than better. Every useMemo and useCallback you write demands React to hold on to a previous value in memory, then compare dependencies on every render, and lastly decide whether the cached thing can be reused. All of that bookkeeping is real work that runs regardless of whether it saves you anything. So before I tell you when and where memoization earns its place, we have to back up to the thing it is built on top of, the concept that the entire mechanism quietly depends on: the stable reference.

    This is not one of those articles that explains “passed by value v/s passed by reference” and stops. Believe me, if you stick till the end, it’ll be worth your time.

    Primitives are passed by value

    To understand what a reference even is, it helps to start with the things that do not have one. In JavaScript, primitives, which are your numbers, strings, booleans, null, undefined, etc. are passed by value, and passed by value simply means that when you assign or pass a primitive, what travels is a copy of the actual value itself and not a pointer to some shared thing.

    let a = 5;let b = a;b = 10;console.log(a); // 5console.log(b); // 10

    When b was assigned from a, it received its own independent copy of the number, so reassigning b to 10 later has no effect on a, because the two were never connected in the first place. This is also why comparing primitives just works the way your intuition expects: 5 === 5 is true because there is nothing to compare but the values themselves, and two identical values are, by definition, equal.

    Non-primitives are passed by reference

    Non-primitives, which are your objects, arrays and functions, behave in a fundamentally different way, because these are passed by reference. This single distinction is the thing that everything else in this post rests on. When you create an object, the actual data lives somewhere in memory, and the variable you assign it to does not hold that data directly, it holds a reference to it, which you can think of as the memory address where the real thing lives, a small slip of paper with the location written on it rather than the contents themselves.

    let a = { name: "React" };let b = a;b.name = "Vue";console.log(a.name); // "Vue"console.log(b.name); // "Vue"

    Here b = a did not copy the object, it copied the slip of paper, so now both a and b hold the same address and both point at the very same object in memory, which is why mutating it through b is visible through a, they were two names for one thing all along. Additionally, equality on non-primitives compares the references, the addresses, and not the contents.

    A reference is equal to another reference only when both are literally pointing at the same underlying thing in memory, and holding that idea firmly in your head is the entire prerequisite for everything that follows, because “stable reference” is just a name for a reference that keeps pointing at that same underlying thing over time.

    What a stable reference looks like

    Let us make this concrete with the smallest React app that can demonstrate it, one that imports a function from lodash and uses it inside a component.

    import { debounce } from "lodash";function SearchBox() {  const handleChange = debounce((value) => {    console.log("searching for", value);  }, 300);  return <input onChange={(e) => handleChange(e.target.value)} />;}

    Set aside the debounce call for a moment and look only at the imported debounce itself. That function is created exactly once, when the module is first evaluated, and it lives at some fixed address in memory from that point onward. Every single time SearchBox renders, and it may render hundreds of times over the life of the app, the identifier debounce reaches out to that same one instance sitting at that same one address, because the import is not re-run on render, it was resolved once at the top and never again. This nature of a component reaching for the same instance of a non-primitive across the whole lifecycle of the app, the reference never changing out from under you, is precisely what we mean by a stable reference.

    Stability comes from where the thing is created, not from the import

    It is tempting to read the example above and conclude that the stability came from the import, but the import is not the important part, it is a consequence of something more general. What actually makes debounce stable is that it lives at module scope, the top level of the file, and module-scope code runs once when the module is first loaded and then never runs again. So the rule is not really about imports at all, it is about the creation site, and any function or object created at module scope is created a single time and is therefore stable everywhere it is used.

    const config = { threshold: 0.5 };function Observer() {  // `config` is the same object on every render of Observer  useEffect(() => {    console.log(config.threshold);  }, []);  return null;}

    config here was never imported from anywhere, it was declared with a plain const in the same file, and it is every bit as stable as the lodash import was, because it too is created once at module scope and read, not recreated, on every render.

    There is one clarification worth stating plainly so the rule does not mislead you, and it is that “outside the component” has to mean “at a scope that does not re-run on render,” which in practice means module scope. If you were to define one component inside the body of another component, the inner definition would be recreated on every render of the outer one, so being lexically outside the JSX is not enough on its own. Stability comes from the code that creates the thing not being executed again, and module scope is simply the most common place where that is true.

    What an unstable reference looks like

    Now flip it around. Any non-primitive that is created inside the component body is created fresh on every single render, because the component body is exactly the code that React runs again each time it renders, and running that line again produces a brand new object or function at a brand new address. The old one is left for garbage collection and a new reference takes its place, and this render-to-render churn of identity is what we call an unstable reference.

    function Toolbar() {  // brand new function object on every render  const handleClick = () => {    console.log("clicked");  };  return <button onClick={handleClick}>Save</button>;}

    handleClick does the same thing every render, but it is not the same function every render, because each render evaluates that arrow function expression afresh and hands back a new reference that is !== the one from the render before. To the naked eye the behaviour is identical, but by reference identity, the thing that non-primitive comparison actually cares about, it is a different function each time.

    There is one exception to all of this within React, and it is the state setters returned by useState. The setter, which is the second element of the array pair, is guaranteed by React to be a stable reference for the entire life of the component, so it is never lost or recreated between re-renders. This is a promise React makes on purpose which allows for state setters to be safely passed down without a second thought.

    function Counter() {  const [count, setCount] = useState(0);  // `setCount` is the exact same reference on every render, guaranteed  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;}

    So the mental model splits cleanly in two: things created at module scope are stable, things created inside the component body are unstable, and the one exception you can lean on is that useState setters are stable.

    When any of this actually matters

    None of this matters until an unstable reference gets compared, and the classic scenario is a prop handed down to a child.

    Imagine a child component that on render, does something very expensive, for example a heavy computation work you very much do not want repeated for no reason.

    A component like that has to be designed so that it only re-renders when it truly needs to, and React’s rule for when a component re-renders is straightforward: a component re-renders when its parent re-renders, and, if it is memoized, when its props actually change.

    Here is where the unstable function turns into a real cost. Suppose the parent passes one of its inline functions down to that expensive child as a prop.

    function Parent() {  const [count, setCount] = useState(0);  // unstable: a new function every time Parent renders  const handleSelect = (id) => {    console.log("selected", id);  };  return (    <>      <button onClick={() => setCount((c) => c + 1)}>{count}</button>      <ExpensiveChild onSelect={handleSelect} />    </>  );}

    Clicking the button re-renders the Parent. handleSelect is recreated as a fresh reference, and that fresh reference is passed into ExpensiveChild as the onSelect prop. ExpensiveChild, by design, dutifully re-renders and performs its expensive work, all because handleSelect‘s reference was changed. Even though the previous version of the function is functionally exactly the same, in spirit, the ExpensiveChild has no way of knowing it.

    useCallback gives you back a stable reference

    This is the exact hole useCallback is shaped to fill. It hands you back a function whose reference is preserved across renders, as long as its dependencies have not changed, so instead of a new function every render you get the same one handed back to you, stable, until you explicitly tell it a dependency changed.

    const handleSelect = useCallback((id) => {  console.log("selected", id);}, []);

    With an empty dependency array this handleSelect keeps the same reference for the life of the component, much like the module-scope function did, except it is allowed to close over props and state and refresh itself when those change, and this is important to note.

    But now the important question: Does wrapping the function in useCallback stop the child from re-rendering? Noooope. Not on its own, not yet. Because here is the mechanism that actually governs it: an un-memoized child re-renders simply because React re-renders its element as part of re-rendering the parent’s tree. There is no gate stopping it. When the parent’s function runs again, it produces brand new element objects for its children, so the child’s incoming props are a fresh object by reference on every parent render, and without React.memo there is nothing that treats “same props” as a reason to skip the work.

    useCallback needs React.memo to do the job

    To actually stop the re-render, you have to change the child’s rule, and that is what React.memo does, it wraps a component so that it no longer re-renders because its parent did, and instead re-renders only when its props change by reference comparison. Once the child component is memoized, the stable function you built with useCallback finally has something to prove itself against.

    const ExpensiveChild = React.memo(function ExpensiveChild({ onSelect }) {  // expensive work here only runs when props actually change  return <div>{/* ... */}</div>;});function Parent() {  const [count, setCount] = useState(0);  const handleSelect = useCallback((id) => {    console.log("selected", id);  }, []);  return (    <>      <button onClick={() => setCount((c) => c + 1)}>{count}</button>      <ExpensiveChild onSelect={handleSelect} />    </>  );}

    Now the two pieces work as a pair. React.memo tells the child to re-render only when its props change, and useCallback guarantees that the onSelect prop does not change by reference on every parent render. So, when the parent re-renders from the counter, the child compares its props, sees the same stable handleSelect it had before, finds no change, and skips its expensive render entirely. That’s a win, isn’t it? Neither can do this alone. React.memo without a stable prop still sees a new function each render and re-renders anyway, and useCallback without React.memo leaves the child bound to its parent’s every render, re-rendering right alongside it regardless of how stable the prop is.. It is the combination that allows you the skipped work, and this pairing is the single most common thing I check for when I see useCallback in a review, because a useCallback passing props to a child that is not wrapped in React.memo is, more often than not pure cost with no benefit.

    So is useCallback ever useful on its own?

    It absolutely is. This is the case that gets forgotten because the React.memo pairing is commonly used, at least from what I have seen. useCallback is useful the moment you need to pass a function into a hook’s dependency array. To see why that matters, we have to understand what a dependency array is actually for. You list a value in a hook’s dependencies when that value is used inside the hook, and you do it so the hook re-runs when the value changes. The thing that goes wrong when you omit the function from the dependency is the stale closure, where the hook reads a value from a render that is long gone.

    function App() {  const [count, setCount] = useState(0);  const logCount = () => {    console.log(count);  };  useEffect(() => {    const id = setInterval(logCount, 1000);    return () => clearInterval(id);  }, []); // ❌ logCount omitted from the dependency array  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;}

    Let’s trace and observe the flow.

    • First render: count is 0, and logCount is created as a function that closes over that first-render count of 0.
    • The effect runs, sets up the interval referring to that particular logCount, and because the dependency array is empty, the effect never runs again for the life of the component.
    • You click the button: count becomes 1, the component re-renders, and a new logCount is created that closes over 1.
    • But the interval is still holding the original logCount from the first render, the one frozen around count with the value 0.
    • So it goes on logging 0 forever, no matter how many times you click.
    Console output0000even though the UI shows:2

    That frozen-in-time read is the stale closure. The effect is looking at a snapshot of state from a render that is returned and long gone.

    The instinct is to fix it by adding logCount to the dependency array, and that is correct, but on its own it introduces the very problem we spent this whole post describing. Because logCount is an unstable reference and recreated on every render, an effect that depends on it would tear down and rebuild its interval on every single render which defeats the point of the interval. This is the case where useCallback shines with no React.memo anywhere in sight.

    function App() {  const [count, setCount] = useState(0);  const logCount = useCallback(() => {    console.log(count);  }, [count]);  useEffect(() => {    const id = setInterval(logCount, 1000);    return () => clearInterval(id);  }, [logCount]); // ✅ stable between renders, changes only when count changes}

    Now logCount is stable across renders that do not touch count, and it changes its reference only when count actually changes, so the effect depends on something stable. It re-runs exactly when the value it closes over changes and stays put otherwise. Here useCallback is not preventing a child re-render at all, there is no child to begin with. It is controlling the identity of a function so that a dependency array can do its job correctly and that is a completely legitimate, React.memo-free reason to use it.

    So the whole thing comes back to where we started, that memoization is a tool for controlling reference identity, nothing more and nothing less, and it is only worth its cost when reference identity is actually the thing hurting you, whether that is a memoized child comparing a prop or a dependency array comparing a function.

    Understand stable references first, and every decision about useMemo, useCallback and React.memo stops being a superstition you apply everywhere and becomes a specific answer to a specific question about identity.