React · lesson 6 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Also part of: Next.js
Effects and useEffect
See StrictMode's real double-invoked mount effect, then a cleanup function that actually runs on unmount and before every dependency change.
By SkillByExample · Published
An effect is a function passed to useEffect that runs after React updates the DOM, for the kind of work a render shouldn’t do directly — subscribing to something, or synchronizing with code outside React. It runs more often in development than most people expect, for real reasons.
StrictMode’s Real Double-Invoke
Wrap a component in <StrictMode>, and in development its mount effects run, clean up, and run again — on purpose.
// src/effects/Ticker.tsx
import { useEffect, useState } from "react";
export function Ticker() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("effect ran, count =", count);
});
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
}
// src/effects/Ticker.test.tsx
import { StrictMode } from "react";
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Ticker } from "./Ticker.js";
it("StrictMode mounts, unmounts and remounts once, doubling the mount effect", async () => {
const user = userEvent.setup();
render(
<StrictMode>
<Ticker />
</StrictMode>
);
await user.click(screen.getByRole("button", { name: "+1" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
$ npx vitest run effects/Ticker.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/effects/Ticker.test.tsx > StrictMode mounts, unmounts and remounts once, doubling the mount effect
effect ran, count = 0
effect ran, count = 0
stdout | src/effects/Ticker.test.tsx > StrictMode mounts, unmounts and remounts once, doubling the mount effect
effect ran, count = 1
✓ src/effects/Ticker.test.tsx > StrictMode mounts, unmounts and remounts once, doubling the mount effect 138ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:45:09
Duration 31.67s (environment 65%, setup 23%, import 10%, transform 1%)
effect ran, count = 0 prints twice before the click, not once. That’s not a bug in this effect — StrictMode deliberately mounts the component, fires its effects, simulates an unmount, and mounts it again, specifically so an effect that forgets to clean up after itself (a subscription left open, a listener never removed) shows a visible symptom in development instead of only failing later. After the click, count is now 1 and the effect runs once, because StrictMode’s extra cycle only wraps the initial mount, not every later render.
A Cleanup Function That Really Runs
An effect can return a function; React runs it before the effect runs again for a changed dependency, and on unmount.
// src/effects/RoomStatus.tsx
import { useEffect } from "react";
export function RoomStatus({ roomId }) {
useEffect(() => {
console.log(`subscribed to room ${roomId}`);
return () => console.log(`unsubscribed from room ${roomId}`);
}, [roomId]);
return <p>Room: {roomId}</p>;
}
// src/effects/RoomStatus.test.tsx
import { it } from "vitest";
import { render } from "@testing-library/react";
import { RoomStatus } from "./RoomStatus.js";
it("cleans up the old room before subscribing to the new one, and on unmount", () => {
const { rerender, unmount } = render(<RoomStatus roomId="general" />);
rerender(<RoomStatus roomId="random" />);
unmount();
});
$ npx vitest run effects/RoomStatus.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/effects/RoomStatus.test.tsx > cleans up the old room before subscribing to the new one, and on unmount
subscribed to room general
unsubscribed from room general
subscribed to room random
unsubscribed from room random
✓ src/effects/RoomStatus.test.tsx > cleans up the old room before subscribing to the new one, and on unmount 23ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:45:44
Duration 35.47s (environment 70%, setup 28%, transform 1%)
Four lines for three renders (mount, a roomId change, unmount) — "general" is unsubscribed the moment roomId changes to "random", before "random"’s own subscription log appears, and "random" is unsubscribed again on unmount. React always runs the previous cleanup before the next effect, never after.
Practice
1. Render Ticker without StrictMode and confirm the mount effect logs only once.
Solution
// src/effects/practice/TickerNoStrict.test.tsx
import { it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Ticker } from "../Ticker.js";
it("without StrictMode, the mount effect runs only once", async () => {
const user = userEvent.setup();
render(<Ticker />);
await user.click(screen.getByRole("button", { name: "+1" }));
});
$ npx vitest run effects/practice/TickerNoStrict.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/effects/practice/TickerNoStrict.test.tsx > without StrictMode, the mount effect runs only once
effect ran, count = 0
stdout | src/effects/practice/TickerNoStrict.test.tsx > without StrictMode, the mount effect runs only once
effect ran, count = 1
✓ src/effects/practice/TickerNoStrict.test.tsx > without StrictMode, the mount effect runs only once 145ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:46:23
Duration 36.28s (environment 67%, setup 22%, import 10%, transform 1%)
One log at mount (count = 0), one after the click (count = 1) — exactly one effect run per render, confirming StrictMode was what doubled the earlier example.
2. Build a ticking counter with setInterval inside an effect, and confirm the interval is actually cleared on unmount using fake timers.
Solution
// src/effects/practice/IntervalLogger.tsx
import { useEffect, useState } from "react";
export function IntervalLogger() {
const [ticks, setTicks] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setTicks((t) => t + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>Ticks: {ticks}</p>;
}
// src/effects/practice/IntervalLogger.test.tsx
import { it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { IntervalLogger } from "./IntervalLogger.js";
afterEach(() => vi.useRealTimers());
it("ticks every second until unmounted, then the interval is cleared", () => {
vi.useFakeTimers();
const { unmount } = render(<IntervalLogger />);
act(() => vi.advanceTimersByTime(3000));
expect(screen.getByText("Ticks: 3")).toBeInTheDocument();
unmount();
act(() => vi.advanceTimersByTime(5000));
console.log("pending timers after unmount:", vi.getTimerCount());
expect(vi.getTimerCount()).toBe(0);
});
$ npx vitest run effects/practice/IntervalLogger.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/effects/practice/IntervalLogger.test.tsx > ticks every second until unmounted, then the interval is cleared
pending timers after unmount: 0
✓ src/effects/practice/IntervalLogger.test.tsx > ticks every second until unmounted, then the interval is cleared 54ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:47:03
Duration 32.43s (environment 75%, setup 24%, transform 1%)
vi.getTimerCount() is logged as 0 after unmount even once more fake time has passed — the cleanup’s clearInterval really removed the pending timer, not just stopped the component from seeing it.
3. Write an effect with no dependency array at all, and measure how many times it runs across two clicks.
Solution
// src/effects/practice/NoDepsArray.tsx
import { useEffect, useState } from "react";
export let effectRuns = 0;
export function NoDepsArray() {
const [count, setCount] = useState(0);
useEffect(() => {
effectRuns++;
});
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
}
// src/effects/practice/NoDepsArray.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NoDepsArray, effectRuns } from "./NoDepsArray.js";
it("an effect with no dependency array runs after every render", async () => {
const user = userEvent.setup();
render(<NoDepsArray />);
const runsAfterMount = effectRuns;
await user.click(screen.getByRole("button", { name: "+1" }));
await user.click(screen.getByRole("button", { name: "+1" }));
console.log("effectRuns:", runsAfterMount, "->", effectRuns);
expect(effectRuns).toBe(runsAfterMount + 2);
});
$ npx vitest run effects/practice/NoDepsArray.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/effects/practice/NoDepsArray.test.tsx > an effect with no dependency array runs after every render
effectRuns: 1 -> 3
✓ src/effects/practice/NoDepsArray.test.tsx > an effect with no dependency array runs after every render 159ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:47:38
Duration 32.88s (environment 68%, setup 20%, import 11%, transform 1%, tests 1%)
effectRuns goes from 1 (the mount render) to 3 after two clicks — up by exactly 2. With no array, there’s nothing for React to compare, so the effect runs after every single render, click or not.