Skip to main content
React · lesson 6 of 22
React intermediate Lesson 6 of 22

Also 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

react hooks useEffect StrictMode

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.

Frequently Asked Questions

Why does my effect run twice when I just added StrictMode?
In development, StrictMode intentionally mounts a component, runs its effects, unmounts it, and mounts it again to simulate what would happen if an effect's state were reused later (as with React's offscreen/Activity features). This only happens in development; a production build mounts once.
What does an effect's cleanup function actually do?
It's the function an effect returns. React calls it before running the effect again for a changed dependency, and again on unmount. It exists to undo whatever the effect set up — unsubscribing, clearing a timer, closing a connection — so the old setup doesn't leak into the next one.
What's the difference between no dependency array and an empty one?
No array at all means the effect runs after every render. An empty array, [], means it runs once after the initial mount and never again, with its cleanup running only on unmount — correct only when the effect doesn't actually read any prop or state value, since one it reads but doesn't list would then be stale. A list of specific values re-runs the effect only when one of those values changes between renders.
Does the cleanup function run before the new effect or after?
Before. React always runs the previous render's cleanup first, then the new effect — for both a dependency change and an unmount. That ordering is what lets an effect that resubscribes to a changed value clean up the old subscription before opening the new one.