Exploring useEffectEvent

31. March, 2024 5 min read Teach

A hook you can’t ship yet

Before anything else: useEffectEvent is experimental. It is not in React 18, it will not be in your next npm install react, and the documentation page for it carries a warning that the API may change or be dropped entirely. Everything below is worth reading anyway, because the problem it solves is one you already have.

The React docs cover it under separating events from effects, with the API reference living at experimental_useEffectEvent. To try it you need the canary or experimental release channel, and the import is deliberately ugly so you can’t forget what you signed up for:

import { experimental_useEffectEvent as useEffectEvent } from 'react';

The problem

Here’s the situation the hook exists for. A chat room connects to a server, and when the connection succeeds you show a notification styled with the current theme:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);

    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();

    return () => connection.disconnect();
  }, [roomId, theme]);
}

The linter is right to demand theme in that dependency array; the Effect reads it. But the consequence is absurd. Toggle dark mode and the chat disconnects and reconnects, because as far as React is concerned the Effect’s inputs changed and it has to be torn down and set up again.

Remove theme from the array and the reconnection stops, but now you’ve lied to React and you’ll eventually show a notification in last week’s colours. Both options are wrong, and this is the trap I’ve watched people fall into over and over. The dependency array conflates two different questions: what should this Effect re-run for, and what values should it read when it does run.

What the hook does

useEffectEvent splits those questions apart. The non-reactive part moves out into an effect event, which always sees the latest props and state but never causes the Effect to re-synchronise:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);

    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();

    return () => connection.disconnect();
  }, [roomId]);
}

theme is gone from the dependency array, and not by cheating. It’s read inside onConnected, which is not reactive, so it genuinely isn’t an input to the Effect. Only roomId is. Change the theme and the connection stays up; change the room and it reconnects. That’s the behaviour you wanted in the first place, finally expressible.

Notice what’s missing: onConnected itself does not go in the dependency array either. That’s not an oversight to be silenced with a lint comment, it’s the rule.

The rules

There are three, and they’re narrower than most hooks:

  1. Only call effect events from inside Effects.
  2. Never pass them to another component or hook. Not as a prop, not as an argument.
  3. Never put them in a dependency array.

The second one catches people out, because passing a callback down is such a normal thing to do. The reasoning is that an effect event has no stable identity from the outside; it’s a hole in the reactivity graph, and letting it travel would make it impossible to reason about who triggers what. If you want to hand a callback to a child, that’s an ordinary prop and an ordinary event handler.

Which is the other half of the picture. useEffectEvent is not a replacement for onClick. Logic that runs because a user did something belongs in an event handler and was never an Effect’s business. The hook is for the narrower case where the Effect owns something external, a socket, a subscription, an observer, and part of the code reacting to it should not participate in the Effect’s lifecycle.

Living without it

Since you can’t ship it, what do you do today? The usual approximation is the latest-ref pattern:

import { useCallback, useLayoutEffect, useRef } from 'react';

function useEventCallback(fn) {
  const ref = useRef(fn);

  useLayoutEffect(() => {
    ref.current = fn;
  });

  return useCallback((...args) => ref.current(...args), []);
}

The returned function has a stable identity, so it’s safe to leave out of dependency arrays, and it always calls the most recent closure. I’ve used variations of this for years, and it’s closely related to the memoisation work I wrote about in optimizing React components.

It is not the same thing, though, and it’s worth understanding where it falls short. Nothing stops you calling it during render, which reads a value that may not be committed yet. Nothing stops you passing it to a child. It’s a convention held together by discipline, whereas the real hook can enforce its rules because React owns the call site. That gap is precisely why the React team never blessed the userland version, and why this has taken so long to land.

One practical annoyance while you wait: the stable eslint-plugin-react-hooks doesn’t know what an effect event is, so if you do experiment with the canary build, exhaustive-deps will cheerfully tell you to add onConnected to the array. Ignore it there. That’s the one place the rule is wrong.

Where this leaves me

I’m not putting a canary React into anything that matters, so useEventCallback stays in my utils folder for now. What has changed is how I read my own Effects. Whenever the dependency array has an entry that makes me wince, that entry is almost always a value the Effect reads rather than a value it should re-run for, and now I have a name for the distinction 🙂

Worth watching the React releases page for when this graduates. Until then it’s a useful way to think about Effects, and a hook you can’t have.

‘Till next time!