Lesson 1.8

Deep Dive: useEffect and useEffectEvent

useEffect is React's synchronization hook. It keeps your component in sync with external systems – servers, the DOM, subscriptions. In real apps, though, you quickly hit the dependency dilemma and stale closures.

Prerequisite: This feature requires React 19.2 or later. The examples focus on the conceptual model—you don't need React 19 running in this app to understand the ideas.

What you'll learn
  • Why useEffect is about synchronization, not arbitrary logic
  • How the dependency dilemma appears in real components
  • Why some changing values should not restart an effect
  • How useEffectEvent lets you separate synchronization from reactivity
  • The golden rules for using useEffectEvent safely

useEffect: Synchronization, Not Side-Logic

At its core, useEffect is a synchronization primitive. Its job is to keep your component in sync with an external system — a server connection, a subscription, the DOM, a timer, etc.

React's contract is simple: the effect runs after the first render, and then re-runs whenever the dependencies that control that synchronization change.

The problem: modern apps often need to read fresh, reactive data inside an effect without treating those values as synchronization triggers.

The Dependency Dilemma: The Chat Room

To see why this matters, let's move away from counters and look at a more realistic example: a chat room.

Goal:

  • Connect to a chat server based on a roomId
  • When the connection succeeds, log a message including the current theme ("dark" / "light") for analytics

A natural first attempt might look like this:

jsx
import { useEffect } from "react"

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId)
    connection.connect()

    connection.on("connected", () => {
      // ⚠️ We want to log the theme when we connect
      logConnection(roomId, theme)
    })

    return () => connection.disconnect()
  }, [roomId, theme]) // 👈 The problem is here

  return <h1>Welcome to {roomId}</h1>
}

According to React's rules, this looks correct: because theme is used inside the effect, it must be listed as a dependency.

But from the user's perspective, this is a bug.

  • Desired behavior: switching between light and dark mode should simply repaint the UI
  • Actual behavior: because theme is a dependency, changing it tears down and recreates the connection

The effect is correctly written but modeling the wrong synchronization. The connection depends on roomId, not on theme.

Enter useEffectEvent: Separate Synchronization from Reactivity

We need a way to say: “I want to read this reactive value (theme), but changes to it should not restart the synchronization (the connection).”

useEffectEvent lets you extract reactive-but-non-synchronizing logic into a stable event handler.

Here's the refactored ChatRoom using the React 19.2 API:

jsx
import { useEffect, useEffectEvent } from "react"

function ChatRoom({ roomId, theme }) {
  // 1. Abstract the non-synchronizing logic
  // This function gets a stable identity
  const onConnected = useEffectEvent(() => {
    logConnection(roomId, theme)
  })

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.connect()

    connection.on("connected", () => {
      // 2. Call the stable event handler inside the effect
      onConnected()
    })

    return () => connection.disconnect()
    // 3. 'theme' is no longer a dependency!
    //    'onConnected' is stable, so it doesn't need to be here either.
  }, [roomId])

  return <h1>Welcome to {roomId}</h1>
}

We have separated our logic into two buckets:

  • Dependencies (roomId): values that, when changed, require re-synchronization (disconnect / reconnect)
  • Effect Events (theme): values we want to read when certain events happen, without restarting the effect

How useEffectEvent Works

There are two key ideas behind useEffectEvent.

1. Stable identity

The function returned from useEffectEvent (onConnected in our example) is referentially stable. From React's perspective, it never “looks different” between renders.

Because its identity is stable, you don't include it in the dependency array — and it won't cause the effect to re-run when props or state used inside it change.

2. Fresh values

Even though the returned function is stable, when you call it from inside an effect, it “teleports” into the latest render.

That means it always sees the freshest values of props and state (like the current theme), without forcing the effect that calls it to be reactive to those values.

Golden Rules of useEffectEvent

  1. Only call useEffectEvent handlers inside effects.

    They are designed to be used from useEffect (and similar effect-like hooks), not during rendering.

  2. Do not pass useEffectEvent handlers as props.

    They are optimized for use inside the component that defines them. For event handlers you pass down to children, continue to use regular functions or useCallback.

Think of useEffectEvent as the missing piece that lets you keep effects focused purely on synchronization, while still being able to read the latest reactive data when external systems fire events.

Summary

useEffect is about synchronization: connecting, subscribing, listening, and cleaning up when the synchronizing conditions change.

The dependency dilemma arises when you need to read changing values inside an effect but don't actually want those values to control when the effect restarts.

useEffectEvent cleanly separates synchronization (effect dependencies) from reactivity (values you read when events fire), giving you fresh values without unnecessary teardowns.

Once you internalize this separation, you'll write effects that are simpler, more predictable, and far less error-prone in complex apps.