Back to all notes

useEffectEvent : Read Latest Values in Effects Without Re-running Them

Extract non-reactive logic from effects so you control when they re-run.

2 min read

I wanted an analytics effect to fire on navigation, but it also needed the latest itemCount. Adding itemCount to the dependency array re-runs the effect on every increment. Leaving it out means stale reads. useEffectEvent splits the difference: a stable function that reads the latest values without joining the dependency array.

Stale or noisy

The noisy version. The tracking effect depends on url and itemCount both, so every increment fires another page view when all I care about is navigation:

import { useState, useEffect } from "react";

function trackPageView(url, itemCount) {
  console.log(`Page view: ${url}, Items: ${itemCount}`);
}

function Page({ url }) {
  const [itemCount, setItemCount] = useState(0);

  // Re-runs on every itemCount change, even though we only care on navigation
  useEffect(() => {
    trackPageView(url, itemCount);
  }, [url, itemCount]);

  return (
    <div>
      <h1>Page: {url}</h1>
      <p>Item Count: {itemCount}</p>
      <button onClick={() => setItemCount(itemCount + 1)}>Increment</button>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>useEffectEvent Pattern</h1>
      <Page url="/home" />
    </div>
  );
}

export default App;

Pull the tracking into an effect event

The tracking call moves out of the effect and into an effect event, and the effect keeps only the real trigger. A simplified useEffectEvent implementation is included so the mechanism shows:

import { useState, useEffect } from "react";

function trackPageView(url, itemCount) {
  console.log(`Page view: ${url}, Items: ${itemCount}`);
}

// Simplified useEffectEvent implementation
function useEffectEvent(callback) {
  const callbackRef = { current: callback };
  const stableCallback = (...args) => callbackRef.current(...args);
  stableCallback._update = (newCallback) => {
    callbackRef.current = newCallback;
  };
  return stableCallback;
}

function Page({ url }) {
  const [itemCount, setItemCount] = useState(0);

  const onVisit = useEffectEvent((visitedUrl) => {
    trackPageView(visitedUrl, itemCount);
  });

  // Update the callback ref on every render
  useEffect(() => {
    if (onVisit._update) {
      onVisit._update((visitedUrl) => trackPageView(visitedUrl, itemCount));
    }
  });

  useEffect(() => {
    onVisit(url);
  }, [url, onVisit]);

  return (
    <div>
      <h1>Page: {url}</h1>
      <p>Item Count: {itemCount}</p>
      <button onClick={() => setItemCount(itemCount + 1)}>Increment</button>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>useEffectEvent Pattern</h1>
      <Page url="/home" />
    </div>
  );
}

export default App;

onVisit stays stable across renders, and an effect updates its inner callback with the latest itemCount on every render. The navigation effect calls onVisit(url) keyed by url. Incrementing the count changes what the next call reads, but it doesn’t re-run the tracking.

Small and pure

Effect events stay small and pure: read latest values, run the action, done. They don’t go in dependency arrays, because they’re stable on purpose. And if a value can be derived during render, derive it instead of reaching for effect logic.