DEV Community

Samuel Rouse
Samuel Rouse

Posted on • Edited on

useCallback is a Lie!

Well, not exactly, but it may not work quite how you expect. It didn't work how I expected.

Functions Not Required

The useCallback docs provide a couple of interesting clues (emphasis mine). It's a big block of text, but it's all relevant.

fn: The function value that you want to cache. It can take any arguments and return any values. React will return (not call!) your function back to you during the initial render. On next renders, React will give you the same function again if the dependencies have not changed since the last render. Otherwise, it will give you the function that you have passed during the current render, and store it in case it can be reused later. React will not call your function. The function is returned to you so you can decide when and whether to call it.

Let's break that down:

  • useCallback receives a (function) value and a dependency array.
  • It caches and returns that (function) value.
  • Whenever the calling function is re-run it returns the original (function) value unless the dependencies array changes.
  • Whenever the dependencies change, it will store and return that new (function) value.

Hooks, Closures, and Stale Values

This is really important for functions in React. Functions have access to the scope in which they were created (closures!), including the values of props and other hooks. Most hooks return immutable values, so if the props or other hooks change, the closure in which the callback was created becomes stale. It will still have old values if we call the function. We need to let go of the copy with a stale closure and get a fresh one.

Call Me, Maybe?

The docs say twice that useCallback doesn't call our function. It just holds onto it for us so we can call it later if we ever need to. And the dependencies are there in case our phone number changes scope changes and we need a fresh version with fresh scope.

But since React never calls the function, it doesn't matter if it's a function! React is just storing a value. We've just agreed by naming convention that value will be a function. But it doesn't have to be.

First Class Functions FTW

JavaScript lets us store functions as values and pass them as arguments. That's exactly what this higher-order useCallback function expects us to do. But we can pass a number, an object, a Promise, or anything.

// No dependencies, so it never changes
const something = useCallback([1, '2', 'c', new Date('4')], []);
const another = useRef(something);

something === another.current; // true
Enter fullscreen mode Exit fullscreen mode

Cough SIGNALS Cough

Incidentally, signals return a function rather than a value, where the function is a consistent return and the signal manages the scope internally rather than the component. This is why signals rule don't require manual dependency tracking and do not experience stale values.

Just sayin'.

Simpler useEffect?

If you are hanging onto a simple object or computation, it might look like this:

// useEffect & useState Edition
const MyComponent = ({ input, color, shape }) => {
  const [state, setState] = useState();
  useEffect(() => {
    setState({ input, shape });
  }, [input, shape]);

  return (<Nested ...state />);
};
Enter fullscreen mode Exit fullscreen mode

Or, probably better if you use useMemo for this.

// useMemo Edition
const MyComponent = ({ input, color, shape }) => {
  const state = useMemo(
    () => ({input, shape }),
    [input, shape],
  );

  return (<Nested ...state />);
};
Enter fullscreen mode Exit fullscreen mode

But you could just pass that directly to useCallback.

// useCallback Edition
const MyComponent = ({ input, color, shape }) => {
  const state = useCallback({ input, shape }, [input, shape]);

  return (<Nested ...state />);
};
Enter fullscreen mode Exit fullscreen mode

const useMemoValue = useCallback;

What we really have here is a function that accepts a value and will hold onto it until there is a change in the dependencies array. No types. No function executions. Nothing. Just holding onto some value.

You could even use this as a slightly sketchy useRef alternative, where you can keep and mutate an object until a dependency forces you to update it. If you have a useRef that gets updated in a useEffect, you might be able to merge those two operations with a misused useCallback.

But you probably shouldn't.

You Still Want useMemo

Except in rare cases like the example above where the operation is trivial, the difference between misusing useCallback and correctly using useMemo is that useMemo doesn't do any of the work in the function you pass it until the props change. Whatever is passed to useCallback, it will do that work every time it renders the component. With useMemo the work is creating a function, not executing the contents of that function.

Simple things like restructuring a single object in our example work well with useCallback, but processing an array is a bad choice.


// Only executes the `.map()` when the dependencies change
const good = useMemo(
  () => bigData.map(entry => complexTransform(entry, someState),
  [ bigData, someState ],
);

// Runs the `.map()` every time the component is rendered
const questionable = useCallback(
  bigData.map(entry => complexTransform(entry, someState),
  [ bigData, someState ],
);
Enter fullscreen mode Exit fullscreen mode

To be honest, trying to use useCallback this way is probably a bad choice in general.

Convention is Communication

As the code author, you might understand that useCallback doesn't have to store a callback function and has this "off label" use. However, other developers may not. Even future you would likely pause at that code and wonder if you meant to use a different hook. You would probably end up adding a comment to clarify the usage, and justify your decision to other developers or to your future self.

All of that means that anyone reading that code will have to stop whatever analysis they are doing to read the comment and understand why this obviously wrong code is not actually wrong and isn't part of the problem they're looking for...unless of course it is, because in doing something non-standard we created some other unexpected defect.

So you really do want useMemo. It does what we expect. It does what the next developer expects. It doesn't require extra mental overhead to understand or explain itself.

Consistency provides a lot of benefit. When we decide to write "interesting" or clever code, it comes at the cost of maintainability and future efficiency. Sometimes it's worth it, but often it isn't.

Summary

So, useCallback is a lie. It's not about the callback; it's about the closure. It's good to know this is the case, because it will help you understand why omitting dependencies can cause problems, but it's also good to know not to use it in this way unless you have a really good reason to do so.

Top comments (0)