Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Effect Events with useEffectEvent in React

Turtlekit Editorial Team

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.

restartsreadcallsaccountIdhideAmountsuseEffectonPaidconnects the streamwords the alert
The effect reacts to the account: a new one reconnects. The Effect Event reads the settings at the moment a payout arrives, and changing them restarts nothing.

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.

The latest ref pattern in React

When the latest value is read outside an effect, or the code must run on React 18.

One effect per concern in React

When an effect restarts because it does several jobs at once.

Custom hooks and hook composition in React

When the stream and its handler belong in a hook other screens can call.

Exposing methods with useImperativeHandle in React

When a parent needs to call into a child, not an effect into a handler.