cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

Separating events from effects

Keep an Effect from re-running when a value it only reads changes: tell reactive from non-reactive logic, and use React 19.2's useEffectEvent.

By the end, you can
  • 01
    Tell the two kinds of logic apartDecide whether code should react to a changed value, like connecting to a room, or only read it, like a notification's theme.
  • 02
    Write an Effect EventMove non-reactive logic out of an Effect with useEffectEvent, so the Effect re-runs only for the values it really depends on.
  • 03
    Follow its rulesCall Effect Events only from Effects, keep them out of dependency arrays and props, and never use them to hide a real dependency.

A chat that reconnects when you change the theme

A chat room connects to its server with an Effect, as in Synchronizing with effects. When the connection is ready, it shows a small notification in the corner, in the page's light or dark colors. The chat.js tab is a pretend server that takes 2 seconds to connect; notifications.js shows the notification and logs it. Wait for the first notification, then click Dark theme and watch the console under the result.

A chat whose notification reads the themePop out in the code editor (opens in a new tab)JSX
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) {  useEffect(() => {    const connection = createConnection(roomId);    connection.on("connected", () => {      showNotification(`Connected to ${roomId}`, theme);    });    connection.connect();    return () => connection.disconnect();  }, [roomId, theme]);   return <h3>Welcome to the {roomId} room</h3>;} export default function App() {  const [roomId, setRoomId] = useState("general");  const [isDark, setIsDark] = useState(false);   return (    <div className={isDark ? "page dark" : "page"}>      <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button>      <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button>      <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} />    </div>  );}
ResultLoading…

Lines 6 to 13 are one Effect. Line 7 creates a connection to roomId, and line 11 starts connecting. Lines 8 to 10 tell the connection what to do once it's ready: line 9 shows a notification in the current theme. The Effect reads both roomId and theme, so line 13 lists both, as the linter asks.

Clicking Dark theme logs Disconnected from general and Connecting to general..., and 2 seconds later a new notification. The room didn't change. Nobody asked to reconnect. But theme is a dependency, so React ran the cleanup and the whole Effect again. On a real server, that's a dropped connection every time someone changes a setting.

Two halves. Left: one Effect holds two jobs, connect to roomId and notify in theme. Its array is roomId and theme, so a theme change reconnects. Right: an Effect with roomId in its array holds connect to roomId, and an Effect Event holds notify in theme, reading the latest theme.One Effect, two jobsEffectconnect to roomIdnotify in theme[roomId, theme]a new theme reconnectsSplit in twoEffectconnect to roomId[roomId]Effect Eventnotify in themea new theme changes nothing
On the left, the notification's theme is a dependency of the whole Effect, so a new theme reconnects the chat. On the right, only connecting is in the Effect. The notification moved into an Effect Event, which reads the theme without being a dependency.

The Effect holds two kinds of logic. Connecting should follow roomId: a new room means a new connection. Showing the notification should only read theme at the moment the chat connects: a new theme means nothing to the connection. This lesson shows how to tell the two apart, and how React 19.2's useEffectEvent moves the second kind out of the Effect, so it stops being a dependency.

Real-life analogyCalling a friend

You call the number on a note by the phone. If someone writes a new number on the note, you hang up and dial the new one. While it rings, your mood changes from tired to cheerful. When your friend picks up, you greet them in the mood you're in right then. You don't hang up and redial because your mood changed; the number is what decides the call.

In real life: Dialling the number written on the note
In React: The Effect connecting to roomId
In real life: A new number on the note: hang up and dial again
In React: roomId changes: the cleanup, then the Effect
In real life: When they pick up, you say hello in whatever mood you're in now
In React: The Effect Event reads the latest theme when the chat connects
In real life: Changing your mood doesn't make you redial
In React: theme isn't a dependency of the Effect
The main idea

Logic in an Effect reacts: it runs again when a value it reads changes. An Effect Event holds logic that only reads values. Write it with useEffectEvent and call it from the Effect. It always sees the latest props and state, and it's not a dependency, so changing those values doesn't re-run the Effect.

Event handlers run on clicks, Effects run to stay in sync

You met the difference in Synchronizing with effects: an event handler runs because the person did something, and an Effect runs because the component is on screen. Here is a sharper way to see it. This chat connects with an Effect and sends with a handler. Type a message, click Send, then click Switch room.

Connecting in an Effect, sending in a handlerPop out in the code editor (opens in a new tab)JSX
import { useEffect, useState } from "react";import { createConnection, sendMessage } from "./chat.js"; function ChatRoom({ roomId }) {  const [message, setMessage] = useState("");   useEffect(() => {    const connection = createConnection(roomId);    connection.connect();    return () => connection.disconnect();  }, [roomId]);   function handleSend() {    sendMessage(roomId, message);    setMessage("");  }   return (    <div>      <input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Type a message" />      <button onClick={handleSend}>Send</button>    </div>  );} export default function App() {  const [roomId, setRoomId] = useState("general");   return (    <div>      <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button>      <h3>Room: {roomId}</h3>      <ChatRoom roomId={roomId} />    </div>  );}
ResultLoading…

Both pieces of code read the same two values. The Effect on lines 7 to 11 reads roomId; handleSend on lines 13 to 16 reads roomId and message. Yet they behave very differently:

  • Typing changes message and renders ChatRoom again, but nothing is sent. Line 14 runs only when you click Send (line 21), and it reads whatever message holds at that moment: Sent to general: Hi.
  • Switching rooms changes roomId, and React runs the Effect again on its own: Disconnected from general, then Connecting to travel.... Nobody clicked anything in ChatRoom; the new value was the reason.

How to read the table: each row asks one question, and the two columns answer it for a handler and for an Effect.

Event handlers and Effects, side by side
QuestionEvent handlerEffect
What makes it run?One interaction: a click, a key press, a submit.Being on screen, and any change to a value it reads.
Does it read props and state?Yes, from the render it belongs to.Yes, and each one it reads is a dependency.
Does it run again when a value changes?No. Only when the person does the same thing again.Yes. React re-runs it when a dependency changes.
In the chathandleSend sends the message when you click Send.Connecting to roomId while the chat is on screen.

Reactive values and reactive logic

React has a name for the values the two pieces of code read. Props, state, and any variable the component body works out from them are reactive values: they can be different on the next render. A value declared outside the component is the same on every render, so it isn't reactive.

Which values are reactiveJSX
const serverUrl = "https://chat.example.com";

function ChatRoom({ roomId, theme }) {
  const [message, setMessage] = useState("");
  const title = `Welcome to the ${roomId} room`;
  // …
}

Line 1 declares serverUrl outside ChatRoom: it never changes, so it isn't reactive. The props on line 3, roomId and theme, are reactive. So is the state on line 4, message, and title on line 5, because it's worked out from roomId during each render. This code fragment isn't a whole example, so it doesn't run.

ChatRoom holds four reactive values: roomId, theme, message, and title. serverUrl sits outside the component and isn't reactive. Below, an arrow leads to handleSend, which reads the values but doesn't react, and another to the Effect, which runs again when a value it reads changes.serverUrloutside: not reactiveChatRoomroomIdthememessagetitlereactive valueshandleSend()reads them, doesn't reactEffectre-runs when they change
Props, state, and values worked out from them in the component body can change on any render: they're reactive. serverUrl, outside the component, never changes. An event handler reads reactive values when it runs; an Effect runs again when one it reads changes.

Code that reads reactive values is one of two kinds:

  • Logic in an event handler isn't reactive. It reads the values when the person clicks, and it doesn't run again when they change. Typing a new message isn't a reason to send it.
  • Logic in an Effect is reactive. Every reactive value it reads is a dependency, and React re-runs it when one changes. A new roomId is exactly the reason to reconnect.

Now look at the overview's chat again. Line 9 sits inside the Effect, so it's reactive, and theme became a dependency of the whole connection. But from the person's side, showing the notification is more like an event: it happens once, at the moment the chat connects, and it should use whatever theme the page has then. That's non-reactive logic stuck inside reactive logic.

Move the non-reactive part into an Effect Event

useEffectEvent is a hook, added as a stable part of React in version 19.2. You pass it a function, and it gives you back an Effect Event: a function you call from inside your Effect, whose code reads the latest props and state without making them dependencies. Here is the chat with the notification moved into one. Click Dark theme again.

The notification as an Effect EventPop out in the code editor (opens in a new tab)JSX
import { useEffect, useEffectEvent, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) {  const onConnected = useEffectEvent(() => {    showNotification(`Connected to ${roomId}`, theme);  });   useEffect(() => {    const connection = createConnection(roomId);    connection.on("connected", () => {      onConnected();    });    connection.connect();    return () => connection.disconnect();  }, [roomId]);   return <h3>Welcome to the {roomId} room</h3>;} export default function App() {  const [roomId, setRoomId] = useState("general");  const [isDark, setIsDark] = useState(false);   return (    <div className={isDark ? "page dark" : "page"}>      <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button>      <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button>      <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} />    </div>  );}
ResultLoading…

Line 1 imports useEffectEvent from react. Lines 6 to 8 create the Effect Event, called onConnected, at the top level of ChatRoom, like any hook. Its code, line 7, is the notification, and it reads roomId and theme. Line 13, inside the Effect, calls onConnected() when the connection is ready. The Effect itself no longer reads theme, so line 17 lists only [roomId], and the linter agrees: Effect Events are never dependencies.

Now Dark theme logs nothing: no disconnect, no reconnect. Click Switch room, and then Light theme before 2 seconds pass. The room change reconnects, as it should, and when the chat connects the notification says Notification (light): Connected to travel: onConnected read the theme the page had at that moment, not when the Effect ran.

Three moments side by side. First, you switch to travel: the dependencies become ["travel"], changed, so the Effect connects to travel. Second, you pick the dark theme: the dependencies are ["travel"] again, the same, so the Effect is skipped. Third, 2 seconds later the chat connects and calls onConnected, which reads the theme now on the page: dark.1. switch to travel2. dark theme3. 2 seconds later["travel"]["travel"]sameconnect travelEffect skippedconnectedonConnected()theme: dark
Switching rooms changes roomId, so the Effect runs. Changing the theme doesn't touch the array, so React skips the Effect. When the chat connects, onConnected() reads the theme the page has at that moment: dark.

Compare the ways to write it in the playground. Pick a version, then switch rooms, change the theme, and press Wait 2 seconds to let the chat connect. Watch Effect runs and the theme of each notification.

Playground: does a new theme reconnect the chat?
The theme chat, in the version you pickedJSX
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) {  useEffect(() => {    const connection = createConnection(roomId);    connection.on("connected", () => {      showNotification(`Connected to ${roomId}`, theme);    });    connection.connect();    return () => connection.disconnect();  }, [roomId, theme]);   return <h3>Welcome to the {roomId} room</h3>;} export default function App() {  const [roomId, setRoomId] = useState("general");  const [isDark, setIsDark] = useState(false);   return (    <div className={isDark ? "page dark" : "page"}>      <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button>      <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button>      <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} />    </div>  );}
The pageEffect runs: 2

Welcome to the general room. The page is light.

Chat: connecting to general

  1. Connecting to general...
  2. Disconnected from general
  3. Connecting to general...
Try it yourself
3 new lines in the log.

ChatRoom appeared, so its Effect ran. In development, StrictMode ran the cleanup and the Effect once more. The chat is connecting: press Wait 2 seconds.

This playground replays React's rules for the code shown: it doesn't run the code itself. The lesson's test runs the same three versions with real React 19.3.1 and checks that they log exactly these lines. Changing the version starts the chat over.

With theme in the array, every theme change adds two Effect runs. The middle version is what many people try first: leave theme out and silence the linter with a comment. The chat stops reconnecting, but try switching rooms and then changing the theme before you wait: the notification uses the old theme, marked in the playground. Only the Effect Event both stays connected and shows the right theme.

How to read the table: each row is one way to write the chat, what a theme change does, and which theme the notification uses.

Three ways to read theme in the chat
How the chat reads themeWhen the theme changesThe notification's theme
theme in the arrayThe chat disconnects and connects again.The theme on the page.
theme left out, with the linter silencedNothing happens.The theme from the render the Effect ran in, which can be old.
theme read inside an Effect EventNothing happens.The theme on the page at the moment the chat connects.

Effect Events read the latest props and state

Why is silencing the linter worse than an Effect Event? Each render creates its own Effect, as a closure over that render's props and state. If theme isn't a dependency, React keeps the Effect from an older render, and so does the callback on line 8. Here is that version, live. Click Switch room, then Dark theme within 2 seconds.

Leaving theme out of the array (don't do this)Pop out in the code editor (opens in a new tab)JSX
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) {  useEffect(() => {    const connection = createConnection(roomId);    connection.on("connected", () => {      showNotification(`Connected to ${roomId}`, theme);    });    connection.connect();    return () => connection.disconnect();    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [roomId]);   return <h3>Welcome to the {roomId} room</h3>;} export default function App() {  const [roomId, setRoomId] = useState("general");  const [isDark, setIsDark] = useState(false);   return (    <div className={isDark ? "page dark" : "page"}>      <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button>      <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button>      <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} />    </div>  );}
ResultLoading…

Line 13 tells the linter to stop checking line 14, so theme is missing from the array. Switching rooms ran the Effect while the page was light, and its callback on lines 8 to 10 keeps that render's theme. Changing to dark didn't run the Effect again, so the notification says Notification (light): Connected to travel on a dark page. Worse, the linter can no longer warn you when a later change adds a value the Effect really should react to.

An Effect Event doesn't keep old values. React updates the function behind it each time it commits a render, so a call always runs the latest version, with the latest props and state. That makes Effect Events useful well beyond themes. This product page logs a visit for each page you open, along with how many items are in the cart. Click Add to cart twice, then Cake.

Logging a visit with the cart's item countPop out in the code editor (opens in a new tab)JSX
import { useEffect, useEffectEvent, useState } from "react";import { logVisit } from "./analytics.js"; function ProductPage({ url, itemCount }) {  const onVisit = useEffectEvent((visitedUrl) => {    logVisit(visitedUrl, itemCount);  });   useEffect(() => {    onVisit(url);  }, [url]);   return <h3>Page {url}, {itemCount} in the cart</h3>;} export default function App() {  const [url, setUrl] = useState("/tea");  const [itemCount, setItemCount] = useState(0);   return (    <div>      <button onClick={() => setUrl("/tea")}>Tea</button>      <button onClick={() => setUrl("/cake")}>Cake</button>      <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button>      <ProductPage url={url} itemCount={itemCount} />    </div>  );}
ResultLoading…

The analytics.js tab logs each visit. The page first logs Visit /tea (cart: 0), twice in development. Adding to the cart logs nothing: a fuller cart isn't a new visit. Clicking Cake logs Visit /cake (cart: 2), with the latest count. The Effect on lines 9 to 11 reacts to url, and the Effect Event on lines 5 to 7 reads itemCount without reacting to it.

Notice that line 10 passes url to the Effect Event as an argument, visitedUrl, instead of letting it read url itself. The page's address is part of what happened: a visit to this page. Passing it keeps url in the Effect, where the linter makes sure it stays in the array. It also matters when the call comes later: an argument is the value from when the Effect ran, while a value read inside the Effect Event is the newest one. Exercise 6 shows the difference.

The rules for Effect Events

An Effect Event is a piece of one Effect's code that you've pulled out to the top of the component. That's why its rules are strict: only call it from inside Effects (or from another Effect Event) in the same component, and never pass it anywhere else. React itself enforces the first rule while rendering. Look at the result:

Calling an Effect Event while rendering (a bug on purpose)Pop out in the code editor (opens in a new tab)JSX
import { useEffectEvent, useState } from "react"; export default function App() {  const [itemCount, setItemCount] = useState(0);  const onVisit = useEffectEvent(() => {    console.log(`Visit with ${itemCount} in the cart`);  });   onVisit(); // A bug on purpose: calling it while rendering.   return <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button>;}
ResultLoading…

Line 9 calls onVisit() in the component body, so it runs during rendering, and React throws A function wrapped in useEffectEvent can't be called during rendering. Rendering must stay a pure calculation, as you saw in Keeping components pure; code that should run once the component is on screen goes in an Effect.

Two columns. Call it from: an Effect, or another Effect Event. Never from: rendering, where React throws; an event handler; or another component.Call it froman Effectanother Effect EventNever fromrendering: it throwsan event handleranother component
An Effect Event is a piece of an Effect, so only Effects and other Effect Events in the same component call it. React throws if rendering calls it; the linter flags handlers and other components.

React doesn't throw for the other wrong places; the linter catches them. The rule is react-hooks/rules-of-hooks from eslint-plugin-react-hooks, version 6.1 or later (6.1 came out with React 19.2; the latest in October 2026 is 7.1.1). Older versions don't know about useEffectEvent. Here is why passing one to another component is wrong. App keeps a count and passes an Effect Event to a Ticker that adds to it every second. Watch the console for a few seconds.

Passing an Effect Event to another component (don't do this)Pop out in the code editor (opens in a new tab)JSX
import { useEffect, useEffectEvent, useState } from "react"; function Ticker({ onTick }) {  useEffect(() => {    console.log("Ticker started");    const id = setInterval(() => onTick(), 1000);    return () => {      clearInterval(id);      console.log("Ticker stopped");    };  }, [onTick]);   return null;} export default function App() {  const [count, setCount] = useState(0);  const [step, setStep] = useState(1);  // Don't do this: an Effect Event passed to another component.  const onTick = useEffectEvent(() => setCount(count + step));   return (    <div>      <p>Count: {count}</p>      <button onClick={() => setStep(step + 1)}>Step: {step}</button>      <Ticker onTick={onTick} />    </div>  );}
ResultLoading…

Every second, the console prints Ticker stopped and Ticker started. useEffectEvent on line 20 returns a new function on every render, on purpose. Line 26 passes it to Ticker, whose Effect lists it on line 11. Each tick changes count, App renders again, onTick is a new function, and React restarts the timer. The count still climbs, but the ticker never runs for more than a second. The fix is to keep the Effect Event next to the Effect that calls it:

The Effect Event lives next to its EffectPop out in the code editor (opens in a new tab)JSX
import { useEffect, useEffectEvent, useState } from "react"; function Ticker({ onTick }) {  const onEachTick = useEffectEvent(onTick);   useEffect(() => {    console.log("Ticker started");    const id = setInterval(() => onEachTick(), 1000);    return () => {      clearInterval(id);      console.log("Ticker stopped");    };  }, []);   return null;} export default function App() {  const [count, setCount] = useState(0);  const [step, setStep] = useState(1);  const onTick = () => setCount(count + step);   return (    <div>      <p>Count: {count}</p>      <button onClick={() => setStep(step + 1)}>Step: {step}</button>      <Ticker onTick={onTick} />    </div>  );}
ResultLoading…

Now App passes a plain function (line 21). Ticker wraps the prop in its own Effect Event on line 4, and its Effect on lines 6 to 13 calls onEachTick() and lists nothing, so the console prints Ticker started only when the ticker appears. Click Step a few times: the count jumps by the new step at the next tick, because the Effect Event reads the latest onTick, and with it the latest count and step.

The last rule is about intent. Don't use an Effect Event to hide a value the Effect should react to:

Hiding a real dependency in an Effect Event (don't do this)JSX
function ProductPage({ url }) {
  const onVisit = useEffectEvent(() => {
    logVisit(url);
  });

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

Lines 2 to 4 read url inside the Effect Event, and line 8 gives the Effect an empty array. The linter is happy, but a new url is a new visit, and nothing logs it. The visit logger above passes url from an Effect with [url]. Ask what should cause the code to run again, and keep those values in the Effect.

How to read the table: each row is a place you might call an Effect Event, whether it's allowed, and what happens.

Where an Effect Event may be called
Where you call itAllowed?What happens
Inside an EffectYes.It runs with the latest props and state.
Inside another Effect Event in the same componentYes.The same: it reads the latest values.
While the component rendersNo.React throws A function wrapped in useEffectEvent can't be called during rendering.
In an event handlerNo.It runs, but the linter flags it. A handler already reads the values it needs; use a plain function.
As a prop for another component, or an argument for a hookNo.The linter flags it. It's a new function on every render, so an Effect that lists it re-runs every time.
In a dependency arrayNo.The linter flags it, and the Effect would re-run after every render.

A support chat with all three kinds of code

A real component often uses all three. This support chat sends with a handler, stays connected with an Effect, and shows each reply with an Effect Event that reads the theme and the mute setting. Type Where is my tea?, press Send, and wait for the reply. Then click Mute and Dark theme, and send another.

A support chat: a handler, an Effect, and an Effect EventPop out in the code editor (opens in a new tab)JSX
import { useEffect, useEffectEvent, useState } from "react";import { createConnection, sendMessage } from "./chat.js";import { showNotification } from "./notifications.js"; function SupportChat({ roomId, theme, muted }) {  const [text, setText] = useState("");   const onReply = useEffectEvent((reply) => {    if (!muted) showNotification(reply, theme);  });   useEffect(() => {    const connection = createConnection(roomId);    connection.on("message", (reply) => onReply(reply));    connection.connect();    return () => connection.disconnect();  }, [roomId]);   function handleSend(e) {    e.preventDefault();    sendMessage(roomId, text);    setText("");  }   return (    <form onSubmit={handleSend}>      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message" />      <button>Send</button>    </form>  );} export default function App() {  const [roomId, setRoomId] = useState("orders");  const [isDark, setIsDark] = useState(false);  const [muted, setMuted] = useState(false);   return (    <div className={isDark ? "page dark" : "page"}>      <button onClick={() => setRoomId(roomId === "orders" ? "returns" : "orders")}>Room: {roomId}</button>      <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button>      <button onClick={() => setMuted(!muted)}>{muted ? "Unmute" : "Mute"}</button>      <SupportChat roomId={roomId} theme={isDark ? "dark" : "light"} muted={muted} />    </div>  );}
ResultLoading…

handleSend on lines 19 to 23 sends the text, because the submit is the reason. The Effect on lines 12 to 17 keeps the connection on roomId. Line 14 tells the connection to call onReply for each reply, and the Effect Event on lines 8 to 10 decides what to show: nothing when muted, or a notification in the current theme. Muting and changing the theme log nothing, because neither is a dependency. Switching the room reconnects.

Event handler, Effect, or Effect Event?
  • Send the message when Send is clicked
  • Place the order when Buy is clicked
  • Connect to the room on screen
  • Subscribe to price updates for the product shown
  • Follow the window's width while the page is open
  • Show the connected notification in the current theme
  • Include the cart's item count when logging a visit
  • Show a reply's notification, unless the chat is muted
Try it yourself
0 of 8 correct

Sort each job: is it caused by one interaction, should it re-sync when a value changes, or should it only read the latest values?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "If the Effect re-runs too often, I'll leave the value out of the array." No. The Effect keeps an old value, and the linter can't help you any more. Move the logic that only reads the value into an Effect Event.
  • "An Effect Event is just a stable version of my function." No. It's a new function on every render, on purpose. What stays up to date is the code it runs, which always sees the latest props and state.
  • "Effect Events are for event handlers." No. They're called only from Effects. A click handler already reads the latest values; it doesn't need one.
  • "I can pass an Effect Event to a child or a custom hook." No. Declare it next to the Effect that calls it, in the same component or hook, and pass plain functions around.
  • "useEffectEvent lets me empty my dependency array." No. Values that should make the Effect run again stay in the Effect and in the array. Only logic that should not react moves out.
  • "Logic in an Effect Event never sees new values." No. It's the other way round: it always sees the newest values. It just doesn't make the Effect run again when they change.

Practice exercises

Each exercise shows its result under the code, or when you press Edit & run, with the console under it. Change the code, and the result runs again as you type. Make a guess first, then use the hints.

Exercise 1 · Warm-upWhich theme does the notification use?

In the Effect Event chat, the page is light. You click Switch room, then Dark theme right away. Which theme does the notification use 2 seconds later? Predict first, then try it in the result.

import { useEffect, useEffectEvent, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) {  const onConnected = useEffectEvent(() => {    showNotification(`Connected to ${roomId}`, theme);  });   useEffect(() => {    const connection = createConnection(roomId);    connection.on("connected", () => {      onConnected();    });    connection.connect();    return () => connection.disconnect();  }, [roomId]);   return <h3>Welcome to the {roomId} room</h3>;} export default function App() {  const [roomId, setRoomId] = useState("general");  const [isDark, setIsDark] = useState(false);   return (    <div className={isDark ? "page dark" : "page"}>      <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button>      <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button>      <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} />    </div>  );}
ResultLoading…

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upSend on click, not on every key

    Type Hi in the box and watch the console. How many Sent lines appear? Then make the chat send only when you click Send.

    import { useEffect, useState } from "react";import { sendMessage } from "./chat.js"; export default function App() {  const [message, setMessage] = useState("");   useEffect(() => {    if (message !== "") {      sendMessage("general", message);    }  }, [message]);   return (    <div>      <input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Type a message" />      <button>Send</button>    </div>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeMute without reconnecting

      Wait for the chat to connect, then click Mute. What's the first line it logs? Then change the code so muting never reconnects.

      import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, muted }) {  useEffect(() => {    const connection = createConnection(roomId);    connection.on("connected", () => {      if (!muted) showNotification(`Connected to ${roomId}`, "light");    });    connection.connect();    return () => connection.disconnect();  }, [roomId, muted]);   return <h3>Welcome to the {roomId} room</h3>;} export default function App() {  const [roomId, setRoomId] = useState("general");  const [muted, setMuted] = useState(false);   return (    <div className="page">      <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button>      <button onClick={() => setMuted(!muted)}>{muted ? "Unmute" : "Mute"}</button>      <ChatRoom roomId={roomId} muted={muted} />    </div>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeA cart that doesn't count as a visit

        Click Add to cart once. What does the console log? Then make visits log only when the page changes, still with the latest cart count.

        import { useEffect, useState } from "react";import { logVisit } from "./analytics.js"; function ProductPage({ url, itemCount }) {  useEffect(() => {    logVisit(url, itemCount);  }, [url, itemCount]);   return <h3>Page {url}, {itemCount} in the cart</h3>;} export default function App() {  const [url, setUrl] = useState("/tea");  const [itemCount, setItemCount] = useState(0);   return (    <div>      <button onClick={() => setUrl("/tea")}>Tea</button>      <button onClick={() => setUrl("/cake")}>Cake</button>      <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button>      <ProductPage url={url} itemCount={itemCount} />    </div>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeStop the ticker from restarting

          Watch the console. Which two lines does it print every second? Then keep the timer running without restarting.

          import { useEffect, useEffectEvent, useState } from "react"; function Ticker({ onTick }) {  useEffect(() => {    console.log("Ticker started");    const id = setInterval(() => onTick(), 1000);    return () => {      clearInterval(id);      console.log("Ticker stopped");    };  }, [onTick]);   return null;} export default function App() {  const [count, setCount] = useState(0);  const [step, setStep] = useState(1);  // Don't do this: an Effect Event passed to another component.  const onTick = useEffectEvent(() => setCount(count + step));   return (    <div>      <p>Count: {count}</p>      <button onClick={() => setStep(step + 1)}>Step: {step}</button>      <Ticker onTick={onTick} />    </div>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeLog the page that was visited

            Press Run again and click Cake at once. Wait 2 seconds. Which page does every line name? Then make each line name the page that was really visited.

            import { useEffect, useEffectEvent, useState } from "react";import { logVisit } from "./analytics.js"; function ProductPage({ url, itemCount }) {  const onVisit = useEffectEvent(() => {    logVisit(url, itemCount);  });   useEffect(() => {    // Log the visit 2 seconds later, even if the person has moved on.    setTimeout(() => onVisit(), 2000);  }, [url]);   return <h3>Page {url}</h3>;} export default function App() {  const [url, setUrl] = useState("/tea");   return (    <div>      <button onClick={() => setUrl("/tea")}>Tea</button>      <button onClick={() => setUrl("/cake")}>Cake</button>      <ProductPage url={url} itemCount={2} />    </div>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The Effect Events quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Which values in this component are reactive?

                Read the code, then predictJSX
                const serverUrl = "https://chat.example.com";
                
                function ChatRoom({ roomId }) {
                  const [message, setMessage] = useState("");
                  const title = `Welcome to the ${roomId} room`;
                  // …
                }

                Choose an answer to see the explanation.

              2. Question 2 of 7The chat is connected to general. You click Dark theme. What does the console print?

                Read the code, then predictJSX
                import { useEffect, useEffectEvent, useState } from "react";
                import { createConnection } from "./chat.js";
                import { showNotification } from "./notifications.js";
                
                function ChatRoom({ roomId, theme }) {
                  const onConnected = useEffectEvent(() => {
                    showNotification(`Connected to ${roomId}`, theme);
                  });
                
                  useEffect(() => {
                    const connection = createConnection(roomId);
                    connection.on("connected", () => {
                      onConnected();
                    });
                    connection.connect();
                    return () => connection.disconnect();
                  }, [roomId]);
                
                  return <h3>Welcome to the {roomId} room</h3>;
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7Where may you call an Effect Event?

                Choose an answer to see the explanation.

              4. Question 4 of 7What happens when this component renders?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useEffectEvent, useState } from "react";
                
                export default function App() {
                  const [itemCount, setItemCount] = useState(0);
                  const onVisit = useEffectEvent(() => {
                    console.log(`Visit with ${itemCount} in the cart`);
                  });
                
                  onVisit(); // A bug on purpose: calling it while rendering.
                
                  return <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button>;
                }

                Choose an answer to see the explanation.

              5. Question 5 of 7What goes wrong when you list an Effect Event in the dependency array, like this?

                Read the code, then predictJSX
                const onConnected = useEffectEvent(() => {
                  showNotification(`Connected to ${roomId}`, theme);
                });
                
                useEffect(() => {
                  // …
                }, [roomId, onConnected]);

                Choose an answer to see the explanation.

              6. Question 6 of 7This page logs a visit when it appears. You click Cake. What does the console add?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useEffect, useEffectEvent, useState } from "react";
                
                function ProductPage({ url }) {
                  const onVisit = useEffectEvent(() => {
                    console.log("Visit", url);
                  });
                
                  useEffect(() => {
                    onVisit();
                  }, []);
                
                  return <h3>Page {url}</h3>;
                }
                
                export default function App() {
                  const [url, setUrl] = useState("/tea");
                
                  return (
                    <div>
                      <button onClick={() => setUrl("/cake")}>Cake</button>
                      <ProductPage url={url} />
                    </div>
                  );
                }

                Choose an answer to see the explanation.

              7. Question 7 of 7In this chat the linter was silenced, and the page is dark. You click Switch room, then Light theme a second later. Which theme does the notification use?

                Read the code, then predictJSX
                import { useEffect, useState } from "react";
                import { createConnection } from "./chat.js";
                import { showNotification } from "./notifications.js";
                
                function ChatRoom({ roomId, theme }) {
                  useEffect(() => {
                    const connection = createConnection(roomId);
                    connection.on("connected", () => {
                      showNotification(`Connected to ${roomId}`, theme);
                    });
                    connection.connect();
                    return () => connection.disconnect();
                    // eslint-disable-next-line react-hooks/exhaustive-deps
                  }, [roomId]);
                
                  return <h3>Welcome to the {roomId} room</h3>;
                }

                Choose an answer to see the explanation.

              Key takeaways

              • Event handlers run because of one interaction. Effects run, and run again, to stay in sync with the values they read.
              • Props, state, and values worked out from them during render are reactive. Every one an Effect reads is a dependency.
              • Logic that should only read a value, not react to it, goes in an Effect Event: useEffectEvent(fn), part of React since 19.2.
              • An Effect Event always sees the latest props and state, and it's never a dependency.
              • Call it only from Effects in the same component. Calling it while rendering throws; handlers, props, and arrays are flagged by the linter.
              • Pass the parts of the event as arguments, and never use an Effect Event to hide a real dependency.

              An Effect reacts; an Effect Event only reads.
              Keep what should re-sync in the Effect, and move what should only see the latest values into useEffectEvent.

              Coming next: Removing effect dependencies. It collects every way to make an Effect re-run less often by changing its code, Effect Events included.

              CompleteFrontend Clear concepts. Working examples.