Skip to main content

Command Palette

Search for a command to run...

The useEvent Hook for React

And how it relates to useCallback

Published
5 min read
The useEvent Hook for React
M

Software Engineer @ Memfault, Ex-Fitbit, Ex-Pebble

Dan Abramov has opened an RFC proposing the addition of a useEvent hook to React. It's meant to allow for this change in your codebase:

- const onClick = useCallback(() => console.log("Hello", name), [name]);
+ const onClick = useEvent(() => console.log("Hello", name));

Performance Optimization

When you're running into performance issues, you'll eventually discover the higher-order component memo (which is quite similar to the pre-hooks base class PureComponent). By default React will always re-render the whole subtree of a component. If one of the child components is wrapped in memo though, that component will (by default) perform a shallow equality check of its props, and prevent React from traversing the tree further if they didn't change.

Since this relies on shallow equality checks, you'll start using useCallback a lot:

function MyComponent({ name }) {
  const onClick = useCallback(() => {
    console.log("Hello", name);
  }, [name]);

  return <MyMemoButton onClick={onClick} />;
}

const MyMemoButton = memo(...);

Note how you have to pass name as a dependency here. If you didn't, then clicking the button would render a stale value: the first name that was passed to the component.

This feels very crufty and annoying, especially as the dependency list grows.

Habitual useCallback

Especially when you're working on a large application with many people, the churn when you eventually have to add memo to one component is high. You'll have to find and change all the places where props are passed to it to use useMemo or useCallback. If contexts are involved, it's even harder. It's also dangerously easy to cause a performance regression because someone passed another prop to your component and didn't make the effort to use useMemo.

The habit of using useCallback and useMemo means less churn, less regressions, less performance surprises, and less mental overhead while writing and reviewing code. You can even make this conventional, and enforce it to some extent with the jsx-no-bind ESLint rule in your codebase (which by default will not allowArrowFunctions).

Good Habit or Bad Habit

There are arguments to avoid useCallback and useMemo and only do this optimization once your app becomes slow.

The core arguments are that using them

  1. makes code harder to write and read,

  2. adds constant baseline cost (time and memory), and

  3. adds variable memory cost (by unnecessarily holding references).

While (1) is absolutely true, (2) should be in the realm of nanoseconds and bytes, while (3) requires a bit more context:

When returning large, transformed objects from useMemo this is true. However in that case the calculation of that object was also probably expensive. So holding onto is also more likely to be preferable. When using useCallback for event handlers, they will be referenced by the DOM anyway.

If we can fix the developer experience of (1), then I'd happily accept (2) and (3) if I get a scalable application in exchange. Furthermore, it's not just about performance but also about side-effects. We'll get to that in a second.

Addressing the Developer Experience

If you have been using React for a while, you might remember that before we had hooks, this felt easier:

class MyComponent extends React.PureComponent {
  handleClick() {
    console.log("Hello", this.props.name);
  }

  render() {
    return <MyPureButton onClick={this.handleClick} />;
  }
}

With useEvent we get this experience back!

function MyComponent({ name }) {
  const onClick = useEvent(() => {
    console.log("Hello", name);
  });

  return <MyMemoButton onClick={onClick} />;
}

Side-Effects

Your codebase might have side-effects, often around data loading.

useEffect(() => {
  setOtherParties([]);
  api.listParties(date)
    .then(parties => parties.filter(b => b.host !== user))
    .then(setOtherParties);
}, [date, user, setOtherParties]);

Note how this effect triggers whenever the date or the user changes (for example when changing accounts). Extracting a predicate naively would lead to issues. Since filter is not stable, it would constantly cause the effect:

const filter = (party) => party.host !== user;

useEffect(() => {
  setOtherParties([]);
  api.listParties(date)
    .then(parties => parties.filter(filter))
    .then(setOtherParties);
}, [date, filter, setOtherParties]);

The exhaustive-deps ESLint rule (which does more than its name suggests) would call out that issue and encourage you to fix this by wrapping it in useCallback, like this:

const filter = useCallback((party) => party.host !== name, [name]);

If the name changes, the reference will change, and will cause the effect when intended. However, the lint rule can not check this across component boundaries!

Predicates, Setters & Loaders

In the situation above, useEvent would probably be wrong and a bug.

const filter = useEvent((party) => party.host !== name);

If you were to do the following, a change of the name would not cause the effect.

Effects & Events

If we look at a different example, something is wrong:

const greet = useCallback((name) => {
  console.log(lang == "es" ? "Hola" : "Hello", name);
}, [lang]);

useEffect(() => {
  greet(user.name);
}, [user]);

When the user changes, then a new greeting is emitted. But when the locale changes, the user is greeted again.

const greet = useCallback((name) => {
  console.log(lang == "es" ? "Hola" : "Hello", name);
}, [lang]);

useEffect(() => {
  greet(user.name);
}, [user]);

In this case, useEvent will give us the desired behavior.

But isn't this exactly like…

It might seem that useEvent might just be the same as some other hooks that we already have, but it's not.

// stable, but stale name on second invocation
const greet = useRef((name) => console.log(name)).current;

// stable, but stale name on second invocation
const greet = useCallback((name) => console.log(name), []);

// not stable
// useLatest coming from a library like react-use
const greet = useLatest((name) => console.log(name));

Closing Notes

So when do I use which? My rough guideline is that if a callback is named on..., handle..., dispatch..., set... and has no return value, it's a good candidate for useEvent. Otherwise there's a good chance it's more of a predicate or loader, in which case useCallback is the preferred choice still.

It would be great if the ESLint exhaustive-deps ESLint rule could treat return values of useEvent like useRef and know that they are stable, so that one doesn't need to include them in dependencies further down. I had to fork the ESLint plugin to make it do so.

As I went through our codebase, I found that I was able to change more than 90% of our useCallback to useEvent, reducing quite a bit of cruft. A great change!