Before sharing her screen with the board, a finance lead ticked “Hide amounts” on the payouts page. In that moment a payout reached the company’s bank, and the page never mentioned it. The next payout appeared, without its amount, exactly as it should. The one in between was gone.
Ticking the box had disconnected the page from the live payout stream and connected it again. The stream doesn’t replay what it sent while nobody listened, so a payout landing in that short gap went unseen. The effect behind it was correct by the dependency linter, and it reconnected because the alert’s wording had changed.
The alert didn’t need a new connection; it needed the new setting. useEffectEvent gives it exactly that. It turns the part of an effect that reacts to an event, here “a payout arrived”, into an Effect Event. That is a function the effect calls, which always sees the latest props and state without making the effect restart when they change.
A setting that reconnected the stream
The stream reports each payout to a handler and returns the way to disconnect. The page words the alert with a small helper that knows about the setting:
// A payout as the live stream reports it, and the stream itself.
export interface Payout {
id: string;
/** In cents. */
amount: number;
currency: string;
}
export interface PayoutStream {
/**
* Listens for payouts to one account; returns the way to
* disconnect.
*/
connect: (
accountId: string,
onPaid: (payout: Payout) => void,
) => () => void;
}
export function money(locale: string, payout: Payout): string {
const format = new Intl.NumberFormat(locale, {
style: "currency",
currency: payout.currency,
});
return format.format(payout.amount / 100);
}
export function paidText(
payout: Payout,
locale: string,
hideAmounts: boolean,
): string {
if (hideAmounts) return "A payout reached your bank.";
return `A payout of ${money(locale, payout)} reached your bank.`;
}The first version of the alerts put all of it in one effect:
// Avoid: hiding the amounts disconnects the stream and connects it
// again
useEffect(() => {
return stream.connect(accountId, (payout) => {
setMessage(paidText(payout, locale, hideAmounts));
});
}, [stream, accountId, locale, hideAmounts]);The handler reads locale and hideAmounts, so the linter asks for both in the list, and it is right to. Leave them out and the handler keeps the values from the first render, so ticking the box would change nothing. Listed, they make the connection restart whenever the wording would change. Neither choice describes what the code means, because the effect is doing two kinds of work.
Some code reacts, some code only reads
react.dev draws the line in two sentences. Logic inside effects is reactive: when a value it reads changes, it runs again. Logic inside event handlers is not: it runs when the event happens and reads whatever is current at that moment. The connection is reactive to the account. The alert is an event, and it only needs to read the setting.
An Effect Event is an event handler that an effect fires instead of a person. It is declared with useEffectEvent, called from inside the effect, and left out of the effect’s dependencies, because reading values without reacting to them is its whole purpose.
Splitting the alert from the connection
Two steps: move the alert into an Effect Event, then move the connection and its event into a hook that other pages can call.
The alert as an Effect Event
// Not reactive: reads hideAmounts and locale as they are when it is
// called.
const onPaid = useEffectEvent((payout: Payout) => {
setMessage(paidText(payout, locale, hideAmounts));
});
// Reactive: connects again only for another account or another
// stream.
useEffect(() => {
return stream.connect(accountId, (payout) => onPaid(payout));
}, [stream, accountId]);onPaid reads hideAmounts and locale; the effect reads only stream and accountId, and lists exactly those. The linter agrees, because it knows that a function returned by useEffectEvent isn’t a dependency. Ticking the box re-renders the page, the next call to onPaid sees the new value, and the connection stays open.
The connection as a hook
"use client";
// Listens for payouts to one account. The caller's handler is wrapped
// in an Effect Event here, so callers can pass any function without
// reconnecting.
import { useEffect, useEffectEvent } from "react";
import type { Payout, PayoutStream } from "./payouts";
export function usePayoutStream(
stream: PayoutStream,
accountId: string,
onPayout: (payout: Payout) => void,
) {
const onPaid = useEffectEvent(onPayout);
useEffect(() => {
return stream.connect(accountId, (payout) => onPaid(payout));
}, [stream, accountId]);
}Wrapping the caller’s handler inside the hook is the arrangement react.dev recommends for custom hooks that take an event handler. A page can pass an inline function, read any state it likes, and never reconnect the stream by accident. The Effect Event is declared in the same hook as the effect that calls it, which is where the rules want it.
The finished alerts
"use client";
// Payout alerts on the payouts page: a live line for each payout,
// which can leave the amounts out while someone shares their screen.
import { useId, useState } from "react";
import { paidText } from "./payouts";
import type { PayoutStream } from "./payouts";
import { usePayoutStream } from "./use-payout-stream";
interface PayoutAlertsProps {
accountId: string;
stream: PayoutStream;
locale: string;
}
export function PayoutAlerts({
accountId,
stream,
locale,
}: PayoutAlertsProps) {
const id = useId();
const [hideAmounts, setHideAmounts] = useState(false);
const [message, setMessage] = useState<{
id: string;
text: string;
} | null>(null);
usePayoutStream(stream, accountId, (payout) => {
setMessage({
id: payout.id,
text: paidText(payout, locale, hideAmounts),
});
});
return (
<section aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>Payouts</h2>
<input
id={`${id}-hide`}
type="checkbox"
checked={hideAmounts}
onChange={(event) => setHideAmounts(event.target.checked)}
/>
<label htmlFor={`${id}-hide`}>Hide amounts</label>
{/* Empty until the first payout; a new line per payout, so two
that read the same are both announced. */}
<p role="status">
{message ? (
<span key={message.id}>{message.text}</span>
) : null}
</p>
</section>
);
}A payout of €1,200.00 is announced as “A payout of €1,200.00 reached your bank.” Tick “Hide amounts” and the next one reads “A payout reached your bank.”, with the stream connected the whole time. Showing another account’s payouts connects to that account and disconnects from the last.
Where Effect Events stand, and their rules
useEffectEvent is a stable part of React since React 19.2, imported from react like any other hook. Before that it existed only in experimental builds, as experimental_useEffectEvent. The linter needs a current eslint-plugin-react-hooks, which knows Effect Events and never asks for them in a dependency list.
Called from effects, and nowhere else
An Effect Event can be called only from inside an effect or another Effect Event, including callbacks the effect creates, such as the stream’s handler here. It can’t be called during render or from a click handler, and it isn’t passed to other components or hooks. For a callback that a button or a child needs, use a plain function. The linter enforces these rules, so the boundary is checked rather than remembered.
Pitfall
An Effect Event is not a way to leave a value out of the dependencies. If a change to the value should restart the effect, as a new account must reconnect the stream, it belongs in the list. Wrapping it to quiet the linter brings back the stale-value bug the linter was preventing.
Its identity changes on purpose
The function returned by useEffectEvent is a new one on every render, by design. If code wrongly depends on it, say by listing it, the effect restarts on every render and the mistake shows at once. That is the main difference from home-made versions built on a latest ref, which return one stable function.
Privacy is part of the alert
The status line is a polite live region that starts empty, and each payout puts a new line in it, so two payouts with the same words are both read out, without moving focus. “Hide amounts” changes the words, not whether the alert appears: someone sharing their screen still learns that money arrived. The checkbox is a native control with a visible label, so it can be reached and changed by keyboard before a call begins.
Testing what the setting must not do
A fake stream counts its connections, which is the contract the finance lead needed:
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { PayoutAlerts } from "./payout-alerts";
import type { Payout, PayoutStream } from "./payouts";
function fakeStream() {
const open = new Map<string, (payout: Payout) => void>();
const stream: PayoutStream = {
connect: vi.fn(
(accountId: string, onPaid: (payout: Payout) => void) => {
open.set(accountId, onPaid);
return () => open.delete(accountId);
},
),
};
const pay = (accountId: string, payout: Payout) =>
act(() => open.get(accountId)?.(payout));
return { stream, pay, open };
}
const PAYOUT: Payout = {
id: "po_1",
amount: 120000,
currency: "EUR",
};
test("hiding amounts: next alert changes, no reconnect", async () => {
const { stream, pay } = fakeStream();
render(
<PayoutAlerts
accountId="acct_1"
stream={stream}
locale="en-GB"
/>,
);
expect(screen.getByRole("status")).toHaveTextContent("");
pay("acct_1", PAYOUT);
expect(screen.getByRole("status")).toHaveTextContent(
"A payout of €1,200.00 reached your bank.",
);
await userEvent.click(
screen.getByRole("checkbox", { name: "Hide amounts" }),
);
pay("acct_1", { ...PAYOUT, id: "po_2" });
expect(screen.getByRole("status")).toHaveTextContent(
"A payout reached your bank.",
);
expect(stream.connect).toHaveBeenCalledTimes(1);
});
test("two payouts with the same words are both read", async () => {
const { stream, pay } = fakeStream();
render(
<PayoutAlerts
accountId="acct_1"
stream={stream}
locale="en-GB"
/>,
);
await userEvent.click(
screen.getByRole("checkbox", { name: "Hide amounts" }),
);
pay("acct_1", PAYOUT);
const first = within(screen.getByRole("status")).getByText(
"A payout reached your bank.",
);
pay("acct_1", { ...PAYOUT, id: "po_2" });
const second = within(screen.getByRole("status")).getByText(
"A payout reached your bank.",
);
// A new node in the live region is a new announcement, even with
// the same text.
expect(second).not.toBe(first);
});
test("another account reconnects; the old one disconnects", () => {
const { stream, open } = fakeStream();
const { rerender } = render(
<PayoutAlerts
accountId="acct_1"
stream={stream}
locale="en-GB"
/>,
);
rerender(
<PayoutAlerts
accountId="acct_2"
stream={stream}
locale="en-GB"
/>,
);
expect(stream.connect).toHaveBeenCalledTimes(2);
expect([...open.keys()]).toEqual(["acct_2"]);
});All three pass. Swap in the first version and the first test fails: the stream connects twice.
A client hook
Effects and Effect Events run only in the browser, after hydration, so the hook’s module is marked "use client". A Server Component can load the account and the person’s locale and pass them down; the connection begins once the page is interactive.
React 18, 19 and the Compiler
On React 18, and on React 19 before 19.2, there is no built-in: the same split is made with a latest ref, or with a stable callback built on one, like Turtlekit’s below. Moving to 19.2 replaces those with useEffectEvent where the caller is an effect. The React Compiler memoises values in the component, but it doesn’t change what an effect reacts to. Saying that a value is read and not reacted to is still the code’s job.
When a value should restart the effect
If changing the value means the outside world must change, the value is a dependency. A different account needs a different connection; a different currency on a chart may need it drawn again. Keep such values in the list and let the effect restart.
If the code doesn’t need an effect at all, an Effect Event won’t help. Work caused by a click goes in the click’s handler, and values computed from props belong in render, as Deriving state during render describes.
How Turtlekit keeps handlers current on React 18 and 19
Turtlekit supports React 18 as well as 19, so its components can’t rely on useEffectEvent. The package exports useEventCallback, which returns one function for the component’s whole life that always calls the newest handler. Here it raises a Toast for each payout:
// Payout alerts with Turtlekit, on React 18 or 19: useEventCallback
// returns a function that keeps its identity and always calls the
// latest handler. The alert is a toast, so the page needs a
// ToastProvider above it.
import { useEffect } from "react";
import { useEventCallback, useToast } from "turtlekit";
import { paidText } from "./payouts";
import type { Payout, PayoutStream } from "./payouts";
interface PayoutToastsProps {
accountId: string;
stream: PayoutStream;
locale: string;
hideAmounts: boolean;
}
export function PayoutToasts({
accountId,
stream,
locale,
hideAmounts,
}: PayoutToastsProps) {
const toast = useToast();
const onPaid = useEventCallback((payout: Payout) => {
toast.success(paidText(payout, locale, hideAmounts));
});
// onPaid never changes, so listing it never reconnects the stream.
useEffect(
() => stream.connect(accountId, onPaid),
[stream, accountId, onPaid],
);
return null;
}The stream connects once per account, and the toast reads the newest setting, exactly as the article’s version does. Because the function is stable, it can sit in a dependency list or be passed to a child without restarting anything; on React 19.2, useEffectEvent is the narrower tool when only an effect calls it.
Built on this pattern in Turtlekit: Tabs.
When the linter or React objects
Each of these messages points at a rule above.
useEffectEvent isn’t exported from react
The project is on a React version before 19.2. Upgrade, or keep a latest ref until you can.
The linter says the Effect Event can’t be in the dependency array
Remove it from the list. Its identity changes on every render, so listing it would restart the effect each time.
React says the function can’t be called during rendering
An Effect Event was called from the component body or passed to a child that calls it. Call it from the effect, or use a plain function for the other caller.
Two speeds in one effect
The payout the finance lead never saw fell between two connections that a checkbox had asked for. The effect had followed the rule that keeps effects honest, and the rule had nothing to say about the difference between the account and the wording. An Effect Event lets the code say it. The connection follows the account and nothing else; the alert reads the setting at the moment the money arrives. With the two kinds of work named apart, the page stays connected while people change how it speaks, and the payout in the gap no longer has a gap to fall into.