React 19HooksuseEffectEventArchitectureBest Practices

Beyond useEffect: Solving the 'Dependency Hell' with useEffectEvent

D
Dan Abramov (Inspired)
Featured Guide 25 min read

You've been lying to your dependency array. And it's time to stop.

We've all done it. You need to read a value inside useEffect, but you don't want the effect to re-run when that value changes. So you omit it. You suppress the lint warning. You feel dirty.
useEffectEvent is the confession booth that absolves you of these sins.

The Problem: React's reactivity model is "all or nothing". If you use a variable, it's a dependency. If it changes, the effect runs. But often, we want to read state without reacting to it.

Imagine a chat app. You want to show a notification when a message arrives. You need the current theme color to style the notification.
If you add theme to the dependencies, the effect runs every time the user toggles Dark Mode, showing the notification again. That's a bug.
If you remove theme, the linter yells at you, and you risk stale closures.

02. Meet useEffectEvent

// The Old Way (Buggy or Hacky)
useEffect(() => {
  const connection = createConnection(roomId);
  connection.on('message', (msg) => {
    // ๐Ÿ”ด Re-runs connection if 'theme' changes!
    showNotification(msg, theme); 
  });
  connection.connect();
  return () => connection.disconnect();
}, [roomId, theme]); // ๐Ÿ˜– We don't want 'theme' to restart the connection!

// The New Way (Clean & Correct)
const onMessage = useEffectEvent((msg) => {
  // โœ… Read latest 'theme' without becoming a dependency
  showNotification(msg, theme);
});

useEffect(() => {
  const connection = createConnection(roomId);
  connection.on('message', (msg) => {
    onMessage(msg);
  });
  connection.connect();
  return () => connection.disconnect();
}, [roomId]); // โœ… 'theme' is gone! No restarts.

useEffectEvent creates a special function that is stable (it never changes identity, so you don't need to put it in dependencies) but always has access to the latest props and state.

It essentially "extracts the non-reactive logic" out of your reactive Effect.

03. Logic vs. Reactivity

The Mental Model Split

  • ๐Ÿ”„
    Reactive Code (useEffect): "When Room ID changes, I must re-connect."
    This code must run to keep the app synchronized with the world.
  • ๐Ÿง 
    Non-Reactive Logic (useEffectEvent): "When a message arrives, I check the Theme."
    This code only runs in response to an event, using whatever the state describes at that moment.

04. Real World: Analytics Logging

A classic scenario: You want to log "Page Visited" when the route changes. You also want to include the current `user.id` and `cart.total` in the log payload.

If you put `cart.total` in the dependency array, you will log "Page Visited" every time the user adds an item to the cart!

function PageLogger({ route, user, cart }) {
  // 1. Define the event handler with access to latest state
  const logVisit = useEffectEvent((currentRoute) => {
    analytics.logEvent('page_view', {
      route: currentRoute,
      userId: user.id,   // โšก๏ธ Access latest user
      cartVal: cart.total // โšก๏ธ Access latest cart
    });
  });

  // 2. Trigger it only when route changes
  useEffect(() => {
    logVisit(route);
  }, [route]); // โœ… Triggers ONLY on route change
}

07. Share the Knowledge

๐Ÿ’ก

Did You Know?

Before useEffectEvent, the "ref hack" (putting state in a useRef) was the only way to silence dependencies safely. useEffectEvent is essentially that pattern blessed by the React core team.

#React19 #useEffectEvent #CleanCode

โšก Interactive Playground

import React, { useState, useEffect, useEffectEvent } from 'react';

// ๐Ÿงช Simulation Helper
function createConnection(roomId) {
  return {
    connect: () => console.log('๐ŸŸข Connected to ' + roomId),
    disconnect: () => console.log('๐Ÿ”ด Disconnected from ' + roomId),
    on: (event, callback) => {
       // Simulate unexpected messages
       if(Math.random() > 0.5) setTimeout(() => callback("Hello from Server!"), 1000);
    }
  };
}

export default function ChatRoom() {
  const [roomId, setRoomId] = useState('general');
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);

  // โœ… THE NEW HOOK
  // This function can read 'theme' but doesn't trigger effect re-runs
  const onNotification = useEffectEvent((msg) => {
    const newNote = {
      id: Date.now(),
      text: msg,
      color: theme === 'light' ? 'black' : 'white' // accessing reactive state!
    };
    setNotifications(prev => [...prev, newNote]);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    connection.on('message', (msg) => {
      onNotification(msg);
    });

    return () => connection.disconnect();
  }, [roomId]); // โœ… 'theme' is NOT a dependency here!

  return (
    <div className={`p-8 min-h-screen transition-colors ${theme === 'dark' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-900'}`}>
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-2xl font-bold">useEffectEvent Demo</h1>
        <button 
           onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
           className="px-4 py-2 rounded bg-blue-500 text-white font-bold"
        >
           Toggle Theme ({theme})
        </button>
      </div>

      <div className="mb-8">
        <label className="mr-4 font-bold">Room:</label>
        <select 
          value={roomId} 
          onChange={e => setRoomId(e.target.value)}
          className="p-2 rounded text-black"
        >
           <option value="general">#general</option>
           <option value="random">#random</option>
           <option value="react">#react</option>
        </select>
      </div>

      <div className="border border-gray-400 p-4 rounded-lg min-h-[200px]">
         <h3 className="font-bold mb-4 opacity-50">Notifications Stream</h3>
         {notifications.map(n => (
            <div key={n.id} style={{ color: n.color }} className="mb-2 p-2 border-b border-gray-700/20">
               {n.text}
            </div>
         ))}
         {notifications.length === 0 && <span className="opacity-30">Waiting for messages...</span>}
      </div>
      
      <p className="mt-8 text-sm opacity-60 max-w-md">
        <strong>Try this:</strong> Toggle the theme. The component re-renders, but check your console. You won't see "Disconnected/Connected". The effect stays stable!
      </p>
    </div>
  );
}