The product team’s weekly numbers had one strange row. A finance lead at a design studio had opened the same invoice hundreds of times in a single morning. Nobody does that, and on a call she confirmed she hadn’t: she had opened it once and spent twenty minutes rewording its reference.
Every letter she typed had counted as a view. The server’s logs told the rest: each keystroke had also closed the editor’s live connection and opened a new one. Both came from one effect in the invoice editor, written to do three things at once, with one dependency list for all of them.
Writing it again meant giving each job its own effect. That habit is effect separation: one effect per concern, each keeping one outside system in step with the values it reads, and nothing else. An effect that does one job re-runs when that job needs to, which is the only time it should.
Three jobs and one dependency list
The editor receives an invoice, a live channel that reports a colleague’s changes, and the product’s analytics. Their shapes are small:
// What the invoice editor receives: the invoice, the live channel
// that says when a colleague changes it, and the product's analytics.
export interface Invoice {
id: string;
reference: string;
}
export interface RemoteChange {
invoiceId: string;
by: string;
}
export interface InvoiceChannel {
/**
* Starts listening for changes to one invoice; returns the way to
* stop.
*/
subscribe: (
invoiceId: string,
onChange: (change: RemoteChange) => void,
) => () => void;
}
export interface Analytics {
logView: (invoiceId: string) => void;
}And this is the effect that counted her keystrokes:
// Avoid: three jobs, one list of dependencies
useEffect(() => {
document.title = `${reference.trim() || "Untitled"} · Invoices`;
analytics.logView(invoice.id);
return channel.subscribe(invoice.id, (change) =>
setEditedBy(change.by),
);
}, [invoice.id, reference, channel, analytics]);Each line is correct by itself. The title should follow the reference; a view should be counted; the editor should listen for changes. The dependency list is correct too, which is why the linter was quiet: the effect reads all four values, so all four are listed. The problem is that the list belongs to the effect, not to each line, so a change to the reference re-runs the subscription and the view along with the title.
Nothing crashed. The numbers were wrong and the server worked harder than it needed to, and those are the bugs that last longest, because nobody is looking at them.
Each effect keeps one thing in step
An effect describes how to start keeping something outside React in step with your component, and how to stop. React starts it after the component appears, stops and starts it again when a value it reads changes, and stops it when the component goes away. The dependency list is the answer to one question: when does this synchronisation need to restart?
Three things that restart for different reasons can’t share one answer. react.dev offers a quick test for whether code belongs together: if deleting one effect wouldn’t break the other, they synchronise different things and should be written apart.
Separating the editor’s effects
The split is mechanical, and doing it shows something the combined effect was hiding: one of the three jobs doesn’t need to be an effect at all.
One effect for each job
Each job moves into its own effect, and each lists only what it reads:
// The tab's title follows the reference as it is typed.
useEffect(() => {
document.title = `${reference.trim() || "Untitled"} · Invoices`;
}, [reference]);
// One subscription per invoice.
useEffect(() => {
return channel.subscribe(invoice.id, (change) =>
setEditedBy(change.by),
);
}, [channel, invoice.id]);
// One view per invoice opened.
useEffect(() => {
analytics.logView(invoice.id);
}, [analytics, invoice.id]);Typing in the reference now re-runs the first effect alone. The subscription restarts only when the editor shows a different invoice or receives a different channel, and the view is counted once per invoice opened. The cleanup that the subscription returns now belongs to the subscription alone, instead of being torn down for a title change.
The title is rendered, not synchronised
In React 19 a component can render <title> anywhere in its tree, and React places it in the document’s head. The title becomes part of the output, like the heading beside it, and the first effect disappears. Its children must be a single string, which is why the text is built with a template literal.
The finished editor
"use client";
// The invoice editor. Two effects, each keeping one outside system in
// step with one thing; the tab's title is rendered, so it needs no
// effect.
import { useEffect, useId, useState } from "react";
import type { Analytics, Invoice, InvoiceChannel } from "./invoice";
interface InvoiceEditorProps {
invoice: Invoice;
channel: InvoiceChannel;
analytics: Analytics;
}
// The page renders it with key={invoice.id}, so another invoice
// starts fresh.
export function InvoiceEditor({
invoice,
channel,
analytics,
}: InvoiceEditorProps) {
const id = useId();
const [reference, setReference] = useState(invoice.reference);
// The latest change by a colleague, numbered so each one is
// announced.
const [notice, setNotice] = useState<{
by: string;
count: number;
} | null>(null);
// Listens for a colleague's changes while this invoice is open.
useEffect(() => {
return channel.subscribe(invoice.id, (change) =>
setNotice((last) => ({
by: change.by,
count: (last?.count ?? 0) + 1,
})),
);
}, [channel, invoice.id]);
// Counts one view each time an invoice is opened.
useEffect(() => {
analytics.logView(invoice.id);
}, [analytics, invoice.id]);
const title = `${reference.trim() || "Untitled"} · Invoices`;
return (
<form aria-labelledby={`${id}-title`}>
{/* React 19 places this in the document's head. */}
<title>{title}</title>
<h1 id={`${id}-title`}>Invoice</h1>
<label htmlFor={`${id}-reference`}>Reference</label>
<input
id={`${id}-reference`}
value={reference}
onChange={(event) => setReference(event.target.value)}
/>
{/* Empty until a change arrives; a new line per change, so a
second change by the same colleague is announced too. */}
<p role="status">
{notice ? (
<span
key={notice.count}
>{`${notice.by} changed this invoice.`}</span>
) : null}
</p>
</form>
);
}Rewording the reference changes the tab’s title as you type and does nothing else. When a colleague saves a change, the status line says so, and closing the invoice stops listening. The page renders the editor with key={invoice.id}, so opening another invoice starts with that invoice’s reference instead of the last one’s.
What separate effects change in practice
Splitting effects rarely changes what the screen shows. It changes how often work happens, and how easy it is to see why.
Separate, not fragmented
The rule is one effect per concern, not one effect per line. Subscribing and the cleanup that unsubscribes are one concern and stay in one effect. So are opening a connection and attaching its handlers. react.dev is direct about the opposite mistake: splitting a cohesive process because the code looks cleaner makes it harder to maintain. Ask whether the processes are the same or separate, and let the answer decide.
Fewer restarts, and cheaper ones
The combined effect did three jobs per keystroke, and one of them was a network round trip. The split version still sets the title in an effect on each one; the finished editor runs none on a keystroke, because the title is part of the render that showed the letter. Objects and functions created during render are the usual way separation gets undone later: pass the editor a new analytics object on every render and the view is counted again each time. Create such values once, outside the component or in its parent’s state.
Development runs each effect twice on purpose
In development, Strict Mode starts each effect, stops it and starts it again when the component first appears, to prove the cleanup works. The editor will log two views per invoice there and one in production. react.dev recommends leaving analytics code as it is rather than guarding it with a ref; the guard hides the check and fixes nothing.
Announcing a colleague’s change
The notice lives in a role="status" paragraph that renders empty, so its text is announced politely when a change arrives, without moving focus from the field someone is typing in. Each change puts a new line in it, so a second change by the same colleague is announced as well.
Testing what each effect is for
A fake channel and a spy for analytics are enough to state each effect’s contract:
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoiceEditor } from "./invoice-editor";
import type {
Analytics,
InvoiceChannel,
RemoteChange,
} from "./invoice";
function fakeChannel() {
const listeners = new Set<(change: RemoteChange) => void>();
const channel: InvoiceChannel = {
subscribe: vi.fn(
(
_invoiceId: string,
onChange: (change: RemoteChange) => void,
) => {
listeners.add(onChange);
return () => listeners.delete(onChange);
},
),
};
const send = (change: RemoteChange) =>
listeners.forEach((listener) => listener(change));
return { channel, send, listening: () => listeners.size };
}
const INVOICE = { id: "inv_46", reference: "INV-0046" };
test("typing a reference changes only the title", async () => {
const { channel } = fakeChannel();
const analytics: Analytics = { logView: vi.fn() };
render(
<InvoiceEditor
invoice={INVOICE}
channel={channel}
analytics={analytics}
/>,
);
const reference = screen.getByRole("textbox", {
name: "Reference",
});
await userEvent.clear(reference);
await userEvent.type(reference, "Q4 retainer");
expect(document.title).toBe("Q4 retainer · Invoices");
expect(channel.subscribe).toHaveBeenCalledTimes(1);
expect(analytics.logView).toHaveBeenCalledTimes(1);
});
test("a colleague's change is read; closing stops listening", () => {
const { channel, send, listening } = fakeChannel();
const { unmount } = render(
<InvoiceEditor
invoice={INVOICE}
channel={channel}
analytics={{ logView: () => {} }}
/>,
);
expect(screen.getByRole("status")).toHaveTextContent("");
act(() => send({ invoiceId: "inv_46", by: "Priya" }));
expect(screen.getByRole("status")).toHaveTextContent(
"Priya changed this invoice.",
);
const first = screen.getByText("Priya changed this invoice.");
act(() => send({ invoiceId: "inv_46", by: "Priya" }));
// A second change by the same colleague is a new line, so it is
// announced too.
expect(screen.getByText("Priya changed this invoice.")).not.toBe(
first,
);
unmount();
expect(listening()).toBe(0);
});Both pass. Put the three jobs back in one effect and the first fails at once: the channel is subscribed again for every letter typed.
Effects never run on the server
During server rendering React renders the component and skips its effects, which run only in the browser after hydration. That is one more reason the title moved out of the effect: rendered as <title>, it is in the server’s HTML, so the tab is named before any script runs. The subscription and the view, which need a browser, stay as effects.
React 18, 19 and the Compiler
Effects behave the same in React 18 and 19; rendering <title> from a component is new in React 19, and on 18 the title keeps its own effect. The React Compiler memoises the objects and functions a component creates, which removes many accidental restarts, but it doesn’t merge or split effects: what each one synchronises is still yours to decide.
When the code shouldn’t be an effect at all
Separating effects is the second question. The first is whether the code needs an effect. A value computed from props or state belongs in render, as Deriving state during render shows. Work caused by a click, such as sending the invoice, belongs in the click’s handler, because it happens when someone acts, not when something appears.
Effects are for keeping something outside React in step: a subscription, a timer, a browser API, a widget written without React. If none of those is involved, delete the effect rather than splitting it.
How Turtlekit dialogs take their open state as a prop
Sending the invoice asks for confirmation first. With Turtlekit, the open state is a prop of AlertDialog, and the component keeps the browser’s dialog in step with it:
// Sending the invoice with Turtlekit: the confirmation's open state
// is a prop, and the dialog keeps the browser in step with it.
import { useState } from "react";
import { AlertDialog, Button } from "turtlekit";
export function SendInvoice({
number,
onSend,
}: {
number: string;
onSend: () => void;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Send invoice</Button>
<AlertDialog
open={open}
tone="accent"
title={`Send ${number}?`}
description="The customer gets an email with a link to pay."
confirmLabel="Send invoice"
onConfirm={() => {
onSend();
setOpen(false);
}}
onCancel={() => setOpen(false)}
/>
</>
);
}Your component holds one boolean and no effect. Opening the dialog moves focus into it, Escape and Cancel close it, and closing it returns focus to the button that opened it, each kept in step on its own inside the component.
Built on this pattern in Turtlekit: Chart, Checkbox, Code block, Combobox, Command, Dialog, Dropdown menu, Error summary, Hover card, Navigation menu, Newsletter signup, Popover, Site header, Tabs, Time, Toast and Tooltip.
When an effect runs more than you expect
Most surprises with effects come down to what they depend on.
The effect re-runs on every render
One dependency is new on every render, usually an object or a function created in the component body or passed inline by the parent. Move it outside the component, inside the effect, or into state where it is created.
The effect needs a value but shouldn’t restart for it
Some code inside an effect reads a value without being about it, like a notification that reads the current theme. That part is an event fired from the effect, which useEffectEvent exists for.
Two effects must run in order
Effects in one component run in the order they are written, but code that depends on that order is usually one concern split in two. Put it back in one effect.
An effect should have one reason to run
The finance lead’s hundreds of views came from an effect that passed every check a linter can make, because the checks covered the list and not the reasons behind it. Writing one effect per concern makes each dependency list a sentence about one thing: listen while this invoice is open, count each invoice opened. Read like that, the editor explains itself, and the title that turned out not to need an effect is the usual reward for asking what each effect is for.