I have a component tree where a parent passes a useCallback-memoized handler down to a deeply nested child. The handler closes over a config object that is rebuilt on every parent render (it comes from a custom hook). The child wraps this handler in its own useCallback with an empty dependency array for performance reasons. The problem is that after the first render, the child's handler holds a reference to the stale initial config even though the parent's version is always current.
Here is a minimal reproduction:
// Parent.jsx
const Parent = () => {
const { config } = useConfigHook(); // new object ref each render
const handleAction = useCallback((payload) => {
// config.mode is always correct here in the parent
submitWithConfig(payload, config.mode);
}, [config]);
return ;
};
// Child.jsx
const Child = React.memo(({ onAction }) => {
const stableHandler = useCallback((e) => {
// Stale: onAction here is the initial render's version
onAction(e.target.value);
}, []); // ← intentional empty array for DOM perf
return ;
});
When config.mode changes (e.g., user switches a setting), the child's handler still calls submitWithConfig with the old mode. The parent's handleAction is recreated correctly, but Child is memoized via React.memo and doesn't re-render, so stableHandler never updates.
What I have tried
1. Adding onAction to the child's dependency array
This is the obvious fix, but it defeats the purpose — HeavyInput receives a new function reference on every parent render, causing it to re-render unnecessarily (profiler confirms ~180ms layout thrash). The empty dep array was intentional.
2. Lifting config into a useRef inside the parent and reading configRef.current inside the handler
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
}, [config]);
const handleAction = useCallback((payload) => {
submitWithConfig(payload, configRef.current.mode);
}, []); // stable ref, reads latest config
This makes the parent's handler stable and always reads the latest config. However, the problem resurfaces at the child level: even though handleAction is now referentially stable, the child's stableHandler is closing over the function identity of onAction from the first render. Since handleAction is now stable, the ref trick only works one level up — but in my real code there is a third intermediary component between Parent and the heavy leaf node, and the closure chain reintroduces staleness at that level.
3. Replacing React.memo on Child with a custom areEqual comparator that always returns true
This prevented Child from ever re-rendering, which did stop the stale closure from being "refreshed" accidentally — but it also made the component entirely static. Any prop changes (including intentional ones like disabled) are now silently ignored.
4. Using useEvent (the RFC proposal) as a polyfill
I implemented the useEvent pattern from the React RFC:
function useEvent(fn) {
const ref = useRef(fn);
useLayoutEffect(() => { ref.current = fn; });
return useCallback((...args) => ref.current(...args), []);
}
This works in the parent, but when I pass the resulting stable function down and the intermediary child wraps it in its own useCallback([], []), the ref-indirection is swallowed — the leaf node calls the intermediary's frozen wrapper, not the ref-backed version. The useEvent guarantee doesn't compose transitively across multiple useCallback layers.
What I need
A pattern that gives the leaf component a truly stable function identity (so HeavyInput doesn't re-render) while still reading the latest closed-over values at call time — and that survives being threaded through one or more intermediary memoized components that each wrap the handler in their own useCallback. Ideally without requiring changes to HeavyInput or introducing a global state manager.