New react callbacks memoization pattern
Use this when you can't use `useCallback`
How to stabilise a memo component when everything else fails
memo looks simple until you wrap a component in it and it re-renders anyway.
So here is the question underneath this whole pattern:
How do you give a memoized child function props that never change identity,
when the parent rebuilds those functions on every render
and you can't refactor the parent to stop?
Let me trace it with one example, all the way through.
The running example
A <DataGrid> that renders thousands of cells. Expensive. A perfect memo candidate.
Its parent owns the state and passes handlers down:
const Page = () => {
const [query, setQuery] = useState('')
const [selected, setSelected] = useState<Row[]>([])
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<DataGrid
rows={filterRows(query)}
selected={selected}
onSelect={(row) => setSelected([...selected, row])}
onClearSelection={() => setSelected([])}
onDeleteSelected={() => deleteRows(selected)}
onSortColumn={(col) => sortBy(col)}
/>
</>
)
}
const DataGrid = memo((props: Props) => {
// thousands of cells
})
The handlers are not independent.
onSelect reads and replaces selected.
onClearSelection replaces selected.
onDeleteSelected reads selected.
They are interconnected. They all close over the same state. Hold onto that — it is what breaks the easy fix.
Watch memo fail
memo compares the new props to the old props before re-rendering.
Shallowly. Key by key. With Object.is.
Same reference for every prop → it skips the render.
One new reference → it renders.
Type a single character in the search box:
keystroke
↓
setQuery
↓
Page re-renders
↓
every inline arrow is created fresh: onSelect, onClearSelection, onDeleteSelected, onSortColumn
↓
memo compares: onSelect !== last onSelect
↓
DataGrid re-renders all of its cells
A function created inline is a new object every render.
(row) => ... today is not Object.is-equal to (row) => ... yesterday.
The shallow compare fails on the first callback it checks.
The expensive render runs anyway.
Why useCallback can't save this one
The textbook fix is useCallback: it hands back the same function object between renders, so the comparison passes.
Wrap onSelect:
const onSelect = useCallback(
(row) => setSelected([...selected, row]),
[selected]
)
The callback closes over selected, so selected goes in the dependency array.
Which means onSelect becomes a new object every time selected changes.
Which is every time you select a row.
memo breaks again, for the exact reason it was breaking before.
Drop the dependency instead:
const onSelect = useCallback(
(row) => setSelected([...selected, row]),
[] // stable, but now wrong
)
The reference is stable now. But the closure is frozen at the first render.
selected is always the empty array it was on mount.
Selecting a second row throws away the first.
That is a bug.
This is the trap:
Put the deps in → the reference changes → memo breaks.
Leave the deps out → the closure goes stale → bug.
useCallback alone cannot give you a stable reference and fresh values.
These are just some shallow examples to make a point, but imagine that you have some thousands of lines of code and over thirty interconnected handlers with lots of props themselves. To fix the grid you'd have to get the dependency arrays right across all of them, keep them in sync as the code changes, and probably lift state or thread it through reducers.
That is a real refactor of the parent which I'm not against, but sometime in the real world, you don't have the time to do this.
The escape is a ref
You need one thing the trap denies you: a value that is stable in identity but reads fresh logic.
A function whose reference never changes.
That, when called, runs the newest version of the code.
A plain variable can't do that. useCallback can't do that.
A ref can.
A ref is a box with a stable identity whose contents you can swap.
So:
Hand the child a function that never changes.
Have that function read the real callback out of a ref at call time.
Update the ref to the latest props on every render.
The child sees a frozen reference.
The ref always holds the latest logic.
The two are decoupled.
The hook
useStablePropsCallbacks does this for every function prop at once:
import { useRef } from 'react'
type AnyCallback = (...args: unknown[]) => unknown
// A function of any shape — only used to detect which props are callbacks.
type AnyFunction = (...args: any[]) => any
// The prop names in P whose value is a function.
// Map each key to itself-or-never, then `[keyof P]` collapses it to the union of survivors.
type CallbackKeys<P> = {
[K in keyof P]: P[K] extends AnyFunction ? K : never
}[keyof P]
// P with only those props kept.
type CallbacksOf<P> = Pick<P, CallbackKeys<P>>
const isCallbackProp = <P extends object>(
propKey: string,
props: P
): propKey is string & keyof CallbacksOf<P> =>
typeof (props as Record<string, unknown>)[propKey] === 'function'
export const useStablePropsCallbacks = <P extends object>(
props: P
): CallbacksOf<P> => {
// Refreshed every render so the proxies reach the newest callbacks.
const latestPropsRef = useRef(props)
latestPropsRef.current = props
// The proxy object. Built once, never rebuilt.
const stableCallbacksRef = useRef<CallbacksOf<P> | null>(null)
if (!stableCallbacksRef.current) {
const proxies = {} as Record<string, AnyCallback>
for (const propKey of Object.keys(props)) {
if (!isCallbackProp(propKey, props)) continue
proxies[propKey] = (...args: unknown[]) => {
const latestProps = latestPropsRef.current as Record<string, unknown>
const latestCallback = latestProps[propKey] as AnyCallback
return latestCallback(...args)
}
}
stableCallbacksRef.current = proxies as CallbacksOf<P>
}
return stableCallbacksRef.current
}
CallbacksOf<P> is just "P with the non-function props removed" — it types the return value as only the callbacks. The runtime hook is three moving parts.
latestPropsRef holds the latest props. latestPropsRef.current = props runs on every render, so the box always contains the newest callbacks. (Writing a ref during render is normally a yellow flag; it is safe here because the proxies only ever read "whatever is latest" — they never snapshot a value at a particular render.)
stableCallbacksRef holds the proxy object. The if (!stableCallbacksRef.current) block runs only on the first render. After that the same object comes back forever.
Each proxy is, for one function prop, a wrapper that resolves the real callback at call time: read the latest props, pick the callback for this key, forward the args. Its identity is fixed for the life of the component, but the function it forwards to is always the current one.
Wiring it in
The hook returns stable versions of the function props. Spread them over the real props so they win:
const StableDataGrid = (props: Props) => {
const stableCallbacks = useStablePropsCallbacks(props)
return <DataGrid {...props} {...stableCallbacks} />
}
Order matters. {...props} puts the raw, unstable callbacks in first. {...stableCallbacks} then overrides each function prop with its stable proxy.
A thin outer wrapper stabilizes the references. The memoized inner component receives the stable ones.
The flow, end to end
Type a character in the search box again. This time:
keystroke
↓
setQuery → Page re-renders → new inline callbacks (as before)
↓
StableDataGrid runs
↓
latestPropsRef.current = props (every render — ref now holds the new callbacks)
↓
proxy object returned (same object as last render — built once at mount)
↓
DataGrid receives the same proxy references it saw last time
↓
memo shallow compare passes → DataGrid skips its render
↓
later, user clicks a row → proxy onSelect fires
↓
proxy reads latestPropsRef.current.onSelect → runs the newest closure → fresh `selected`
The expensive grid stayed put through every keystroke.
When a real interaction happened, the call hit the current logic, with the current state.
Stable identity for memo. Fresh closure for correctness. No useCallback, no dependency arrays, no parent refactor.
What this gives up
The callback set is frozen at mount. Object.keys(props) runs once, inside the if block. A handler that is undefined on the first render and supplied later never gets a proxy. If your function props are all present from the start, you are fine. If they appear conditionally, they slip through.
Only function props are stabilized. Object and array props still get new references when the parent recreates them, and will still break memo. Those need their own handling.
The indirection costs you some tooling. Stack traces pass through the proxy. And because the wiring is dynamic, the react-hooks/exhaustive-deps lint can't see it — you trade a class of dependency-array bugs for a class of "invisible to the linter" ones.
When not to reach for it
One or two callbacks? Just use useCallback. It is clearer, and the linter helps you.
This pattern earns its keep in one narrow situation: many interconnected, stateful callbacks, on a genuinely expensive child, where rewriting the parent to hand-stabilize each one isn't worth it.
Inside that window, it is the difference between memo working and memo being decoration. Outside it, it is indirection you don't need.
The whole thing in three beats:
The proxy holds a stable identity.
The ref holds the latest logic.
The memo holds its ground.
Appendix: the types, traced one step at a time
Three lines of types do the heavy lifting in that hook, and they are where most eyes glaze over. Mine did.
type CallbackKeys<P> = {
[K in keyof P]: P[K] extends AnyFunction ? K : never
}[keyof P]
type CallbacksOf<P> = Pick<P, CallbackKeys<P>>
const isCallbackProp = <P extends object>(
propKey: string,
props: P
): propKey is string & keyof CallbacksOf<P> =>
typeof (props as Record<string, unknown>)[propKey] === 'function'
The P is the part that makes it abstract. But P is not a real type — it is a placeholder. A fill-in-the-blank.
It works exactly like a function parameter, one level up. A function parameter stands for whatever value you pass in. P is a type parameter: it stands for whatever type you pass in.
And you pass a type in by writing it in the angle brackets. CallbacksOf<Props> hands Props to the blank called P. From that point on, TypeScript substitutes Props for P everywhere in the definition — keyof P becomes keyof Props, P[K] becomes Props[K], Pick<P, …> becomes Pick<Props, …>.
Here is the type we fill it with:
type Props = {
selected: Row[]
onSelect: (row: Row) => void
onClearSelection: () => void
count: number
}
Two functions. Two non-functions. We want a type that keeps only the two functions.
For every example below, the rule is simple: wherever a definition says P, read Props.
One thing to hold in your head before we start: none of this runs.
These types are erased before your code executes. There is no CallbackKeys object at runtime, no loop, no work. This is the compiler reasoning about shapes — all of it happens in your editor and at build time, then vanishes. So when I say "the value becomes a name," I mean in the type, on the compiler's scratch pad. Nothing is computed when the app runs.
CallbackKeys: filtering keys when the language has no filter
You'd think you could just write keyof Props.
keyof Props gives you the names of every prop, as a union:
"selected" | "onSelect" | "onClearSelection" | "count"
But that is all four. We only want the two that are functions.
And here is the problem: TypeScript has no .filter() for keys. You cannot say "keep the keys where the value is a function." There is no such operator.
So you do it sideways. In three moves.
Move 1 — walk every key and replace its value.
{
[K in keyof P]: P[K] extends AnyFunction ? K : never
}
[K in keyof P] means "for each key K in Props, make an entry."
The value of each entry is not the original type. It is P[K] extends AnyFunction ? K : never — a question asked per key:
Is this prop's value a function?
Yes → put the key's own name as the value.
No → put `never` as the value.
So the object type you get is:
{
selected: never // Row[] is not a function
onSelect: "onSelect" // a function → its own name
onClearSelection: "onClearSelection" // a function → its own name
count: never // number is not a function
}
Read that again. The function keys now hold their own name as a value. The rest hold never.
Move 2 — read all the values at once.
[keyof P] on the end looks like indexing an array. It is indexing a type.
When you index an object type by a single key, you get that key's value. When you index it by a union of keys, you get the union of all those values.
keyof Props is the union of all four keys, so:
{…}["selected" | "onSelect" | "onClearSelection" | "count"]
hands back the union of the four values:
never | "onSelect" | "onClearSelection" | never
Move 3 — never falls out for free.
never is the empty type. In a union it just disappears — never | "onSelect" is "onSelect".
So the whole thing collapses to:
"onSelect" | "onClearSelection"
That is CallbackKeys<Props>. The names of the function props, and nothing else.
The flow, end to end:
Props
↓ keyof P
"selected" | "onSelect" | "onClearSelection" | "count"
↓ map each key → (its own name if a function, else never)
{ selected: never, onSelect: "onSelect", onClearSelection: "onClearSelection", count: never }
↓ [keyof P] — read every value as one union
never | "onSelect" | "onClearSelection" | never
↓ never drops out of a union
"onSelect" | "onClearSelection"
The map-to-name-then-index flow is the missing filter. That is the only reason it exists.
CallbacksOf: turning the names back into an object
Now you have the names. You want the object.
type CallbacksOf<P> = Pick<P, CallbackKeys<P>>
Pick<T, K> is built into TypeScript. It means exactly what it says: pick keys K out of object T, keep their types, drop everything else.
So Pick<Props, "onSelect" | "onClearSelection"> is:
{
onSelect: (row: Row) => void
onClearSelection: () => void
}
Props with the non-functions gone. The values come through untouched.
That is CallbacksOf<Props>. One readable line, because CallbackKeys already did the hard part.
isCallbackProp: a runtime check that teaches the compiler something
This one mixes two worlds, which is what makes it look strange.
const isCallbackProp = <P extends object>(
propKey: string,
props: P
): propKey is string & keyof CallbacksOf<P> =>
typeof (props as Record<string, unknown>)[propKey] === 'function'
The two worlds are both in the signature, and they are easy to mix up because they look alike.
props — lowercase — is the real object your code holds at runtime: the actual { selected, onSelect, … }.
P — uppercase — is its type: Props, the same thing we filled in earlier.
props: P just says "this value has that shape." Lowercase is the value, uppercase is the shape of the value. So in our example, props is an object and P is Props.
Ignore the return type for a second. The body is trivial:
typeof props[propKey] === 'function'
"Is the value at this key a function?" A plain boolean. That part runs at runtime, in the browser, while the hook builds its proxies.
Now the strange part: the return type is not boolean. It is propKey is string & keyof CallbacksOf<P>.
That is a type predicate. It changes what the compiler knows about propKey after the call.
Here is why you need it. Inside the hook:
for (const propKey of Object.keys(props)) {
if (!isCallbackProp(propKey, props)) continue
// ...
}
Object.keys returns string[]. So propKey starts as a plain string. The compiler has no idea which key it is — it could be "count", which is not a callback.
A naive version returning : boolean would check the value at runtime, but the compiler would still see propKey as a plain string afterward. It would not let you treat it as a callback key.
The is predicate fixes that. It tells the compiler:
If this function returns true, then for the code that follows,
you may treat `propKey` as `string & keyof CallbacksOf<P>`.
For our Props, keyof CallbacksOf<Props> is "onSelect" | "onClearSelection". So after the if, the compiler narrows propKey from "some string" to "one of the callback names." Now indexing the callbacks object with it is safe.
The string & part is a small guard. keyof can in general yield string | number | symbol, but Object.keys only ever gives strings — intersecting with string keeps the result a string key. For our Props the keys are already strings, so it changes nothing here; it matters only for the fully generic P.
Now the honest part, because a predicate has a sharp edge.
The body runs. The predicate does not.
typeof … === 'function' is real work the browser does. propKey is … is a promise you make to the compiler, and the compiler believes it without checking. If you wrote a boolean that did not actually match the promise, TypeScript would trust the lie and narrow anyway.
Here the boolean and the promise agree — a prop whose value is a function genuinely is a callback key — so it is sound. But that is on you to get right, not something the compiler proves.
The three types in three beats:
keyof P lists every prop name.
CallbackKeys filters those names down to the callbacks.
isCallbackProp proves, at runtime, that one loose string is really one of them.



