Making Referential Stability a Type
Written on July 24, 2026
Making Referential Stability a Type
If you've worked on a (P)React codebase of any real size, you've had the referential stability<br>conversation more times than you'd like. Someone adds a useMemo, someone else adds a useCallback,<br>an exhaustive-deps warning gets silenced, and we all hope that the prop passed to the next component is stable.<br>The code is usually correct, but we don't have all the guarantees for this implicit stability.
I've spent more hours than I want to admit chasing a re-render or effect execution that came down to a new array literal<br>being handed to a memoized child. The type declared Item[], a prop typed as<br>Item[] tells me about the shape of the type and what's inside that shape but nothing about whether I get a stable<br>array on the next render.
So I've been playing with a small idea: what if the intention of referential stability was part of the type.
The type
The whole thing hangs on a private phantom brand:
declare const stableBrand: unique symbol
type Stable = T extends object ? T & { readonly [stableBrand]: true } : T
Objects, arrays and functions get branded. Primitives pass straight through, because React and Preact already<br>compare those by value, a string is always "stable enough".
"Stable" here does not mean immutable or identical for the lifetime of the<br>component. It means the reference is expected to survive unrelated renders and<br>change only when its source is invalidated, such as by a state update or a<br>memo dependency changing. Stability only within one render would be trivial,<br>every local reference already has that property. This is an optimization<br>contract, not something application correctness should rely on: React can<br>discard memoized values and callbacks when it has a reason to.
The unique symbol is doing the work here, if I'd used a string key like _stable: true,<br>any object that happened to have that shape would satisfy the brand by accident, and someone would<br>write { _stable: true } eventually. A unique symbol that never leaves the package can't be<br>reproduced by structural assignment from application code. You can name Stable, you just can't<br>forge one.
That gives a prop the ability to describe more than its data:
type ItemListProps = {<br>items: StableItem[]><br>onSelect: Stable(id: string) => void><br>title: string
The array and the callback are references constructed with a stable intention, so they carry<br>the brand. And now an array built inline is the wrong type:
ItemList<br>items={items.filter(isVisible)}<br>onSelect={(id) => select(id)}<br>title="Visible items"<br>/>
The component boundary pushes the requirement back onto the caller, which is exactly where it should be.
This is also the half of memo() and PureComponent we forget about. You wrap a component in memo,<br>and assume it won't re-render without value changes, however the stability of complex types isn't a guarantee<br>and can cause this optimization to be nullified.
Keep the hooks, replace the import
My first attempt at producing these proofs used module augmentation. You import the package for its<br>side effect, it augments React's hook types, and useMemo starts branding its result when every<br>dependency is stable.
It half works, and the half that doesn't is worth understanding, because it's the part that sounded<br>weird to me too until I dug into why.
Module augmentation lets you add overloads to a declaration. It never lets you remove or replace the<br>ones @types/react ships. After augmenting, useMemo has two overloads living side by<br>side: the strict (factory, deps: StableDeps) => Stable, and React's original<br>(factory, deps: DependencyList) => T. TypeScript tries the strict one first, and when every<br>dependency carries proof, great, you get Stable. But the moment one dependency doesn't, the strict<br>overload stops matching and resolution quietly falls through to React's original, which happily accepts<br>any DependencyList:
const unstable = {}<br>const value = useMemo(() => ({ answer: 42 }), [unstable])<br>// no error. value is just { answer: number }, unbranded
There's no error at the dependency list, which is the place I wanted one. The proof simply<br>doesn't get produced. So I stopped fighting it. The strict path is a separate entry point:
import { useCallback, useEffect, useMemo } from 'stableref/react'
These are the real React hooks at runtime, exposed under stricter type declarations. The dependency tuple<br>is inferred, and each unproven element is replaced with an actionable string literal in the expected type.<br>Because this entry does not include React's permissive overload, there is nothing to fall back to and a raw<br>reference fails at the dependency where it was written:
const stableFilter = useCallback(filter, [query])
useMemo(() => source.filter(stableFilter), [source, stableFilter])<br>// Stable
useMemo(() => source.filter(rawFilter), [source, rawFilter])<br>// ^ type error
useEffect(syncSelection, [selection, page])<br>// ^ type error when page is an...