Blog
useEffect in production: stale closures, cleanup and effects running at the wrong time
An old subscription updates a new view; a background refresh erases a draft. Define Effect ownership, dependencies and cleanup, then test callbacks and timers without network timing.
After changing branch, the dashboard shows the previous branch’s incidents
An operator switches a library service desk from harbour to hill. The heading changes immediately. A little later, the incident count belongs to Harbour again. Refreshing the page fixes it, until the next switch.
This is a fictional feature, used throughout the article: a library network’s incident dashboard with live updates, optional polling and a shift handover note. It is a plausible production failure, not a report of a GiSoft incident. A long session exposes what a quick local check misses: callbacks created under one selection are still active under another.
The first thing I would trace is the owner of that update. Which branch did the callback capture, and what was supposed to stop it when the operator moved on?
Moment Screen External work
t0 harbour subscription captures harbour
t1 hill old callback remains queued
t2 hill harbour update arrives
Broken: harbour count appears under hill
Correct: old callback is ignored; hill owns its updatesFast local responses and short sessions hide the overlap. Variable latency, background tabs and repeated navigation make it visible. The problem is the lifetime of the work, not HTTP packets supposedly arriving in the wrong order.
What needs an Effect here?
useEffect can synchronise a committed React view with an external resource: a subscription, browser listener, timer or imperative widget. The dependency list identifies the values that require that synchronisation to be renewed. Before setup runs for changed dependencies, React runs the previous cleanup; cleanup also runs when the component unmounts.
“After render” is too vague as a design rule. An Effect belongs to a committed render, runs on the client and is not a universal guarantee that the browser has already painted. A calculation from current props does not need this boundary. A click already has an event handler. Server data may already have a route or API layer responsible for loading it.
The examples use React 19.2 and TypeScript. The inspected frontend has Next.js 16 App Router, Vitest and React Testing Library. DeskFeed and DeskReader below are article-owned contracts, not React APIs or existing project helpers. A real adapter would handle transport authentication, decode incoming data and define reconnection and event ordering.
export type DeskScope = {
branchId: string;
sessionEpoch: number;
};
export type DeskSnapshot = {
branchId: string;
waiting: number;
};
export interface DeskFeed {
watch(
scope: DeskScope,
receive: (snapshot: DeskSnapshot) => void,
): () => void;
}
export interface DeskReader {
read(scope: DeskScope, signal?: AbortSignal): Promise<DeskSnapshot>;
}sessionEpoch is a local generation changed when the authentication context changes. It contains no credential and grants no permission. Its purpose is to retire work belonging to a previous session. The backend must still authorise every request and subscription.
A timer can keep using an old render
This deliberately incorrect hook looks reasonably tidy: it starts a timer and clears it. Yet the empty dependency array keeps the original branchId, sessionEpoch and reader in the callback.
import { useEffect, useState } from 'react';
import type { DeskReader, DeskSnapshot } from './deskContract';
export function useFrozenDesk(
branchId: string,
sessionEpoch: number,
reader: DeskReader,
) {
const [snapshot, setSnapshot] = useState<DeskSnapshot | null>(null);
useEffect(() => {
const timer = window.setInterval(() => {
void reader.read({ branchId, sessionEpoch })
.then(setSnapshot)
.catch(() => setSnapshot(null));
}, 20_000);
return () => window.clearInterval(timer);
}, []);
return snapshot;
}Changing the displayed branch does not recreate that callback. Twenty seconds is merely this example’s polling interval, not a scheduling guarantee. Once a read has started, clearing the interval also does nothing to prevent its eventual response from calling setSnapshot.
That is a stale closure: the callback has retained a render’s values beyond their useful lifetime. Closures are normal JavaScript; the error is expecting one to acquire a later render’s values automatically. A functional state updater can help when computing from previous state, but it does not replace a captured branch ID or authentication context.
Dependencies describe synchronisation, not a preferred frequency
Including the missing dependencies is necessary here, but it leaves the in-flight response problem to solve. If fixing a lint warning produces constant reconnections, inspect what the Effect depends on.
For example, const scope = { branchId, sessionEpoch } in the component creates a different object on every render. Depending on scope renews the Effect even when both fields are unchanged. Dependencies are compared with Object.is, not by inspecting object contents. Build that small object inside the Effect and depend on its primitive inputs instead.
The same applies to inline callbacks and freshly constructed clients. An Effect using feed really does depend on that instance. Give the client a deliberate owner; do not hide it from the dependency list. useMemo or useCallback can stabilise an appropriate value, but neither repairs an Effect that should be a calculation or an explicit action.
The installed stack includes the React Hooks ESLint plugin through the Next.js tooling. No active ESLint configuration or lint script was found in this checkout, so package installation alone is not evidence that exhaustive-deps is running. Enable such checks through the project’s agreed configuration rather than suppressing their warnings in examples.
The subscription belongs to a branch and a session
Here is the live-update version. Each subscription owns a callback and an unsubscribe function. Cleanup withdraws permission to update the view before releasing the subscription, which also covers a callback already queued by the adapter.
'use client';
import { useEffect, useState } from 'react';
import type { DeskFeed, DeskSnapshot } from './deskContract';
type Props = {
branchId: string;
sessionEpoch: number;
feed: DeskFeed;
};
export function DeskActivity(props: Props) {
const identity = `${props.sessionEpoch}:${props.branchId}`;
return <ActivityScope key={identity} {...props} />;
}
function ActivityScope({ branchId, sessionEpoch, feed }: Props) {
const [snapshot, setSnapshot] = useState<DeskSnapshot | null>(null);
useEffect(() => {
let acceptsEvents = true;
const stop = feed.watch({ branchId, sessionEpoch }, next => {
if (acceptsEvents && next.branchId === branchId) {
setSnapshot(next);
}
});
return () => {
acceptsEvents = false;
stop();
};
}, [branchId, sessionEpoch, feed]);
return (
<section>
<h2>{branchId}</h2>
<p role="status">
{snapshot ? `${snapshot.waiting} incidents waiting` : 'Waiting for updates'}
</p>
</section>
);
}The key resets only the live-status subtree when the branch or session changes. It prevents the previous branch’s snapshot remaining under the new heading while the new subscription connects. It does not replace cleanup. Keep a draft form outside this reset boundary unless discarding its draft is explicitly intended.
The branch check catches a misrouted event; acceptsEvents rejects work from a retired subscription, including replacement of feed for the same branch. This small example assumes the adapter delivers ordered snapshots within one live subscription. If the transport can deliver revisions out of order, its contract needs a revision check too.
Resource ownership determines what cleanup means:
Resource owned by the view Release that ownership
Branch subscription unsubscribe from that topic
Shared WebSocket release subscription, not all users
Timer clear the scheduled callback
Browser listener remove the same callback
Read request retire result; abort if supported
Imperative widget call its dispose/destroy contractClosing a shared socket from one panel can break every other panel. Conversely, a component that creates a private connection normally owns its closure. Setup and cleanup should agree on that scope.
A cleanup function can exist and still remove nothing
This second incorrect hook refreshes when tab visibility changes. Every setup adds one function; cleanup passes a newly created function with identical source text.
import { useEffect } from 'react';
export function useLeakingResume(
branchId: string,
refresh: (branchId: string) => void,
) {
useEffect(() => {
document.addEventListener('visibilitychange', () => refresh(branchId));
return () => document.removeEventListener(
'visibilitychange', () => refresh(branchId),
);
}, [branchId, refresh]);
}removeEventListener needs the registered callback identity and matching capture setting. Repeated branch changes therefore accumulate listeners in this version. Returning to the tab can trigger several reads, including reads for branches no longer visible.
Keep the callback in the setup’s scope and release that same callback:
import { useEffect } from 'react';
export function useDeskResume(
branchId: string,
refresh: (branchId: string) => void,
) {
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') refresh(branchId);
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [branchId, refresh]);
}refresh here is a synchronous request trigger owned by the feature. Its implementation handles asynchronous errors and response relevance; this hook only owns the listener. If its identity changes on every render, the listener is safely replaced but unnecessarily resubscribed. Decide where that trigger should live before reaching for memoisation.
Polling needs a lifetime as well as an interval
A slow read can outlast setInterval’s next tick. For this dashboard, polling is an alternative to live updates, not a second writer running alongside them. Scheduling the next read after the previous one settles avoids overlap within a polling lifetime.
import { useEffect } from 'react';
import type { DeskReader, DeskSnapshot } from './deskContract';
export function useDeskPolling(
branchId: string,
sessionEpoch: number,
reader: DeskReader,
receive: (snapshot: DeskSnapshot) => void,
report: (error: unknown) => void,
pauseMs = 20_000,
) {
useEffect(() => {
let retired = false;
let timer: number | undefined;
const controller = new AbortController();
async function refresh() {
try {
const next = await reader.read(
{ branchId, sessionEpoch }, controller.signal,
);
if (!retired) receive(next);
} catch (error) {
if (!retired && !controller.signal.aborted) report(error);
} finally {
if (!retired) timer = window.setTimeout(refresh, pauseMs);
}
}
void refresh();
return () => {
retired = true;
controller.abort();
window.clearTimeout(timer);
};
}, [branchId, sessionEpoch, reader, receive, report, pauseMs]);
}Use this hook inside the same branch/session ownership boundary. receive and report are stable feature callbacks, such as React state setters; the reader contract returns the requested branch’s snapshot. The interval is a minimum pause after completion. Background throttling and scheduling can delay it further.
Cleanup stops the next timer, aborts supported transport work and retires callbacks even if the adapter cannot cancel. A pending call must eventually settle or respect cancellation for polling to continue; this snippet does not implement transport timeouts or backoff. It also assumes the callbacks do not throw. Those are adapter and feature contracts, not extra jobs for a generic timer hook.
Old data, old errors and old loading states are the same ownership problem
Adding dependencies can start read B while read A is still in flight. B may succeed before A fails. If A’s catch handler then writes an error, the current view loses valid data. An unconditional finally { setLoading(false) } has the opposite failure: A finishes while B remains pending, and the spinner disappears too early.
Data, error and loading transitions must belong to the same active operation. Guard all three, or let the established query layer own them. A single boolean is fine for one serial operation; it cannot describe several unrelated requests. The polling hook above prevents overlap within one lifetime and guards callbacks from retired lifetimes. It does not coordinate every request elsewhere in the feature.
Intentional cancellation during navigation usually should not produce a server-failure banner. Inspect the adapter’s cancellation contract: an HTTP wrapper may transform the original error. Checking the owned signal or an explicit cancellation result can be more appropriate than assuming every client exposes AbortError unchanged.
Aborting a read withdraws the client’s interest. Aborting a mutation does not establish that the server rolled it back. Backend permissions, idempotency and concurrency control remain separate responsibilities. The detailed response-order tests and mutation trade-offs belong to Race conditions in React: when an older request overwrites newer state; here the focus is the Effect’s lifetime.
A handover is an action, not a state synchronisation
Sending the shift note through setShouldSend(true) and an Effect watching shouldSend hides the initiating action. Another state restoration or remount may now trigger behaviour originally intended only for a click.
Use an explicit handler. This is a component fragment: pending, draft, the setters and sendHandover come from the feature; the adapter is not a framework API. The example assumes a branch-scoped form that stays on that branch until the operation settles.
async function handleHandover() {
if (pending) return;
setPending(true);
setError(null);
try {
await sendHandover({ branchId, note: draft });
setSent(true);
} catch {
setError('Could not send the handover.');
} finally {
setPending(false);
}
}Bind it to the form’s submit handling, prevent native submission where appropriate and disable submission while pending. The boolean improves the interaction; it is not backend duplicate protection. If the operator can switch context mid-send, the result also needs an operation identity or a dedicated owner. A timeout must not silently become permission to resend a consequential operation.
An Effect remains appropriate when an external system must track rendered state, such as subscribing to the selected branch. A command that happens because someone chose “Send handover” has a clearer cause in the handler.
A background refresh must not silently own the draft
The count of open incidents without an assignee can be calculated from the current list during render. Storing that count separately and updating it in an Effect introduces an extra update and a second value to keep consistent. A small pure function states the rule:
type DeskIncident = {
status: 'open' | 'resolved';
assigneeId: string | null;
};
export function countUnassigned(incidents: readonly DeskIncident[]) {
return incidents.filter(incident =>
incident.status === 'open' && incident.assigneeId === null,
).length;
}The component uses const unassigned = countUnassigned(incidents). There is no separate state to synchronise and no dependency array to maintain.
Drafts need a different decision. An Effect containing setDraft(handover.note) with [handover] looks like synchronisation. When a background refresh replaces that object, it can erase what the operator has typed since opening the form.
Define the contract: does the form own a draft until submission, should external changes replace it, or should a newer server revision display a conflict notice? Resetting on record identity can be sensible, provided navigation has an explicit policy for unsaved work. A new object reference is not proof that the user wanted a reset. Autosave responses need the same care: acknowledge the saved draft version without replacing newer keystrokes.
Effect chains conceal a workflow
In this feature, a branch change could load permissions, an Effect could turn those into canReview, another could load incidents, and a fourth could select the first incident and load its details. Each piece may look harmless in isolation; together they create intermediate states and obsolete work that are hard to attribute.
Derive canReview if it is just a projection of permissions. Where requests genuinely depend on one another, use an explicit asynchronous sequence with one context identity, the route’s data layer or the project’s query abstraction. Keep independently owned subscriptions separate. There is no rule that Effects must never affect the inputs of other Effects; the warning sign is a business process whose order is visible only by following state setters across the component.
Similarly, one Effect should not own the socket, polling, document title and analytics merely because they all start while the panel is visible. Split by external responsibility and cleanup lifetime, not mechanically by line count.
Strict Mode exposes assumptions; it does not define production frequency
With React 19.2 and Strict Mode at the root, development includes an extra Effect setup–cleanup–setup cycle. App Router enables Strict Mode by default in the inspected Next.js generation. A subtree-only Strict Mode boundary has qualifications; do not turn the root behaviour into a statement about every possible tree.
Production does not perform that extra cycle for the same development check. Real dependency changes, navigation and remounting still require correct cleanup. Two development reads are not by themselves proof of a production defect. Two surviving subscriptions after setup has settled are a much stronger signal.
Removing Strict Mode can hide that signal. Test that setup, cleanup and setup leave exactly the intended resources active. Do not build the argument around historical unmounted-state-update warnings: resource leaks and incorrect ownership matter whether a warning appears or not.
Next.js does not make every load an Effect
These interactive examples belong on the client side of an App Router boundary. Effects do not run while generating server HTML. Server Components, route loading and browser subscriptions solve different problems; use the existing loading architecture for data that already has an owner.
This repository has a custom API client and feature services, without a configured query library. It also uses static export for the public frontend, which restricts request-time server features. Advice to “move it to the server” must account for that deployment model. A query library can centralise caching, refetching and cancellation in another project, but it cannot decide whether a background refresh should overwrite a draft.
Hydration mismatch is a separate problem: the initial server and client representations disagree. Reading browser-only state in an Effect may fit a particular design; moving arbitrary rendering logic there is not a general hydration repair. Nor should dozens of components independently rebuild loading, error and cancellation logic when a shared data layer already exists.
Reproduce the stale callback without waiting for the network
For the subscription component, use a small fake boundary. This article’s ManualDeskFeed records connections and exposes their callbacks. Calling a saved callback after unsubscribe deliberately models work that was already queued; it does not mean a correct transport should keep dispatching new events after unsubscribe.
import type { DeskFeed, DeskScope, DeskSnapshot } from './deskContract';
type Connection = {
scope: DeskScope;
receive: (snapshot: DeskSnapshot) => void;
closed: boolean;
};
export class ManualDeskFeed implements DeskFeed {
readonly connections: Connection[] = [];
watch(scope: DeskScope, receive: Connection['receive']) {
const connection = { scope, receive, closed: false };
this.connections.push(connection);
return () => { connection.closed = true; };
}
}The following Vitest/React Testing Library test assumes the usual jsdom setup and @testing-library/jest-dom/vitest matchers. The local imports refer to the article examples. It observes both the visible result and release of the owned subscriptions.
import { act, render, screen } from '@testing-library/react';
import { expect, it } from 'vitest';
import { DeskActivity } from './DeskActivity';
import { ManualDeskFeed } from './ManualDeskFeed';
it('rejects the old branch callback and releases both subscriptions', () => {
const feed = new ManualDeskFeed();
const view = render(
<DeskActivity branchId="harbour" sessionEpoch={1} feed={feed} />,
);
const harbour = feed.connections[0]!;
act(() => harbour.receive({ branchId: 'harbour', waiting: 7 }));
view.rerender(
<DeskActivity branchId="hill" sessionEpoch={1} feed={feed} />,
);
const hill = feed.connections[1]!;
expect(harbour.closed).toBe(true);
expect(hill.closed).toBe(false);
expect(screen.getByRole('status')).toHaveTextContent('Waiting for updates');
act(() => hill.receive({ branchId: 'hill', waiting: 2 }));
act(() => harbour.receive({ branchId: 'harbour', waiting: 99 }));
expect(screen.getByRole('heading')).toHaveTextContent('hill');
expect(screen.getByRole('status')).toHaveTextContent('2 incidents waiting');
expect(screen.queryByText('99 incidents waiting')).not.toBeInTheDocument();
view.unmount();
expect(hill.closed).toBe(true);
});Add a same-branch feed replacement case so the test exercises the retired-callback guard without relying on the keyed unmount. Change sessionEpoch too: old authenticated work must lose ownership even if the branch stays the same. Under root StrictMode, assert one active subscription after setup and none after unmount, rather than making a global assertion that setup ran once.
For the listener, dispatch visibilitychange, rerender for another branch and dispatch again: each visible-tab event should invoke exactly one current-branch trigger. After unmount it should invoke none. For polling, Vitest’s vi.useFakeTimers() and vi.advanceTimersByTimeAsync() can advance scheduled work. Hold the read promise pending to prove another poll does not start; resolve it, advance the pause, then switch context and deliver the old result or rejection. Restore real timers after each test. No wall-clock sleep is needed.
Give each test a specific boundary
The first useful protection depends on where the defect can actually be observed:
Risk First useful protection
Missing reactive dependency Hooks lint + focused behaviour test
Draft/reducer decision unit test of the decision
Obsolete subscription component test with controlled feed
Timer lifetime component test with fake time
Stale data/error/loading component test with held promises
Transport cancellation adapter integration/contract test
Duplicate business effect backend functional/integration test
Navigation across branches selected browser journeyLint and TypeScript catch some dependency and type mistakes; they cannot prove cleanup releases the right resource. Adapter tests cover topic mapping, authentication changes, cancellation and public errors using controlled transport responses. A browser test can cover navigation with delayed responses or events, but exact ordering is cheaper to pin down in the component test.
Also protect the draft: type a note, deliver a newer server object and verify the agreed draft/conflict behaviour. For separate read-based views, explicitly test B succeeding before A fails, and A settling while B still loads. These are different assertions, even when they share the same controlled-promise helper. How to test Next.js architecture, failures and user workflows discusses the wider suite; there is no need to recreate it here or install another runner for this article.
Diagnose ownership before changing the timing
Record enough to reconstruct one failure: branch identity, a non-sensitive session generation, setup and cleanup times, subscription topic and an operation ID. Compare when the user switched branch with when the old callback completed. Keep credentials, tokens, protected payloads and unnecessary personal data out of these logs.
An empty dependency array can freeze the wrong values. Disabling exhaustive-deps hides that fact. Adding every freshly created object can cause constant reconnection. Memoisation, an isMounted flag or a timeout to “wait for state” will not establish which branch owns an update. Start with the cause of the Effect, then decide its inputs and lifetime.
Before merging an Effect, answer five questions:
- What external resource follows this committed view, and could the work instead be a calculation or click handler?
- Which values identify its owner, including changes of authentication context?
- What does setup acquire, and does cleanup release that exact resource?
- Can retired work still change data, loading, errors or a user’s draft?
- Which controlled callback, promise or clock will demonstrate the answer in a test?
For the library dashboard, the fix is a clear lifetime: Harbour’s work stops owning the view when Hill takes over. Choosing the correct dependency array is part of that decision; subscription release, queued callbacks and draft ownership complete it.
Technical references for lifecycle and platform behaviour:
- https://react.dev/reference/react/useEffect
- https://react.dev/reference/react/StrictMode
- https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps
- https://nextjs.org/docs/app/api-reference/config/next-config-js/reactStrictMode
- https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
