Using React’s useCallback hook to preserve identity of partially-applied callbacks in collections

Mat Brown
6 min readMar 17, 2019

--

Recently while transitioning Popcode from the ACE editor to CodeMirror, I came across a situation that I thought was interesting in light of Dan Abramov’s fantastic recent article about useEffect and the mental model for hooks in React. If you haven’t read that article, I highly recommend doing so now.

useEffect (along with other React hooks that take dependencies) requires we be careful about preserving the identity of our callbacks between calls to render — if a callback passed as a prop is given as a useEffect dependency, and that callback unnecessarily changes every render cycle, the effect will re-run every render cycle as well. This can be quite painful if the cost of running the effect is non-trivial.

Turning to the specific problem I encountered, Popcode is a web-based coding environment, similar to CodePen or JS Bin. Like those other environments, Popcode typically has three code editors on screen, one each for HTML, CSS, and JavaScript.

In the new <CodeMirrorEditor> component — the first one in Popcode to use React Hooks — there is an effect which attaches a change listener to the underlying CodeMirror instance, and forwards information about the change to the callback prop given to the component:

function CodeMirrorEditor({onChange}) {
const editorRef = useRef();
// another effect sets up the editor useEffect(() => {
const editor = editorRef.current;
function handleChanges() {
onChange(editor.getValue());
}
editor.on('changes', handleChanges);
return () => editor.off('changes', handleChanges);
}, [onChange]);
// other stuff
}

The only dependency of the effect is the onChange callback itself, and this is as it should be: the job of the effect is to keep the listener attached to the editor synchronized with the callback given to the component.

However, when I first wrote code like this, I noticed that the "changes" handler was getting added to and removed from the editor instances constantly — any time I typed a character into the editor. This would imply that the onChange prop itself was changing with every render, and indeed it was. The component that was including <CodeMirrorEditor>, which itself renders the entire editors column, looked something like this:

function EditorsColumn({sources, onChange}) {
return _.map(sources, (source, language) => (
<CodeMirrorEditor
key={language}
source={source}
onChange={newSource => onChange(language, newSource)}
/>
);
}

This, of course, was creating a new function by way of the function literal given to the onChange prop on each invocation of the render function.

We know that React provides useCallback to allow us to memoize callback functions between render calls, as long as the dependencies of those callback functions don’t change. But useCallback, as the name implies, is designed to be used on a single callback function — in this case we are working with a collection of callbacks.

The simplest approach would be to write three memoized callbacks, since we happen to know that the only possible values for language are "html", "css", and "javascript":

function EditorsColumn({sources, onChange}) {
const handleChange = {
html: useCallback(
newSource => onChange('html', newSource),
[onChange]
),
css: useCallback(
newSource => onChange('css', newSource),
[onChange]
),
javascript: useCallback(
newSource => onChange('javascript', newSource),
[onChange]
)
};
return _.map(sources, (source, language) => (
<CodeMirrorEditor
key={language}
source={source}
onChange={handleChange[language]}
/>
);
}

This works, but it’s unsatisfying for a couple of reasons: first, there’s quite a lot of repetition, and second, it doesn’t generalize beyond a scenario where we have a small, known list of possible values that need to be partially applied to the callback function.

It would be nice if we could just write a callback factory function, using the useCallback hook to prevent unnecessary regeneration:

function EditorsColumn({sources, onChange}) {
function makeHandleChange(language) {
return useCallback(
newSource => onChange(language, newSource),
[onChange]
);
}
return _.map(sources, (source, language) => (
<CodeMirrorEditor
key={language}
source={source}
onChange={makeHandleChange(language)}
/>
);
}

Alas, this is 100% not allowed: the first Rule of Hooks states, “don’t call Hooks inside loops, conditions, or nested functions.”

But it does feel like we’re on the right track with a callback factory. Instead of trying to apply the useCallback hook to each individual callback, can we find a way to hoist the hook up to the top level? Instead of useCallback, let’s try this with useMemo, which is a more general version of useCallback that can be used to retain any kind of value between renders:

function EditorsColumn({sources, onChange}) {
const makeHandleChange = useMemo(() => {
return (language) => {
return newSource => onChange(language, newSource);
};
}, [onChange]);
return _.map(sources, (source, language) => (
<CodeMirrorEditor
key={language}
source={source}
onChange={makeHandleChange(language)}
/>
);
}

There’s quite a bit of nesting here, so let’s break down what we’re doing:

  • Giving useMemo a function which returns a callback factory, which
  • Given a language, returns a function, which
  • Calls the onChange callback that we are given as a prop, with language as the first argument

This seems promising, but it doesn’t solve the original problem: we will still get new callback functions on each render. This is because the callback factory is being memoized, but the factory will still return a new callback each time it’s called.

Concretely: makeHandleChange will be the exact same function instance on each render, but makeHandleChange(language) will still evaluate to a new callback function, because that’s what the implementation of makeHandleChange does.

Fortunately, our needs here are fairly commonplace: we need a function that, each time it is given a certain input, returns exactly the same object. This is nothing more than garden-variety function memoization, and we can use lodash’s memoize function to make our callback factory cache the callbacks it generates:

function EditorsColumn({sources, onChange}) {
const makeHandleChange = useMemo(() => {
return _.memoize((language) => {
return newSource => onChange(language, newSource);
});
}), [onChange]);
return _.map(sources, (source, language) => (
<CodeMirrorEditor
key={language}
source={source}
onChange={makeHandleChange(language)}
/>
);
}

This will do what we want, because:

  • useMemo will guarantee that, as long as the onChange prop stays the same, makeHandleChange will be the exact same callback factory function
  • The callback factory function itself is wrapped with lodash memoize, which will guarantee that each time it’s called with a particular argument, it will return the same result object. So, for instance, calling makeHandleChange('html') will give us the exact same callback function every time.

It may seem odd, and perhaps redundant, that we’re using two similarly-named functions, React’s useMemo and lodash’s memoize, in conjunction. While these two functions both broadly perform memoization, each plays a different and complementary role in the whole process. useMemo is aware of our React component and allows us to keep the callback factory cached between renders of the same component instance. Its job is to cache the value of a particular object — in this case, the callback factory, but useMemo can be used to cache any kind of value. Lodash’s memoize is specifically applied to a function so that that function remembers what value it returned for a given input. memoize is what makes the callback factory worth remembering at all.

The code above works exactly as we would like, but it is a bit hard to parse, particularly with all the nested functions. As it turns out, the React Hooks documentation notes that “useCallback(fn, deps) is equivalent to useMemo(() => fn, deps)”. That means that, even though we’re applying it to a callback factory rather than a callback, we can still use useCallback:

function EditorsColumn({sources, onChange}) {
const makeHandleChange = useCallback(
_.memoize((language) => {
return newSource => onChange(language, newSource);
});
), [onChange]);
return _.map(sources, (source, language) => (
<CodeMirrorEditor
key={language}
source={source}
onChange={makeHandleChange(language)}
/>
);
}

So there we have it: a generalizable way to generate partially-applied callbacks on the fly, while preserving the callbacks’ identity between render calls.

While this was an interesting technical challenge to work through (and if you’ve read this far, I assume you agree), we do have to ask: are Hooks making us do more work? After all, the old approach in <EditorsColumn>, passing new callbacks on every render, worked just fine when used with the <AceEditor> component, which was a traditional class component. Without going too deep into the implementation, this is basically because the <AceEditor> component only cared about the value of the onInput prop when it was initially setting up the editor (and thus the callback), and implicitly ignored any changes to that callback.

While this didn’t happen to result in any bad behavior in the code as written, it’s most certainly a bug in the implementation of <AceEditor>, since onInput could very conceivably change in a meaningful way. In that case, <AceEditor>’s approach of “call back to whatever onInput happened to be when I set up the editor” would yield incorrect behavior.

So, ultimately this supports Dan Abramov’s conclusion in the article I linked at the top — if you use hooks and follow the rules and declare your dependencies exhaustively, we may find ourselves forced to think about things we wouldn’t have otherwise, but they’re things we should be thinking about in order to write correct code.

--

--

Mat Brown

Software engineer, bacon enthusiast, intermittent cyclist.