DrillLab
第 16 / 21 节LESSON 16 / 21约 16 分钟~16 min

变式二 · 计时器:useEffect 的清理函数Variation 2 · a timer: the useEffect cleanup function

这道题真正的考点只有一个 —— 你会不会写 return () => clearInterval(id)。This question really tests one thing: can you write return () => clearInterval(id).

3 个练习3 exercisesReact · 第 5 部分React · Part 5
这一页有什么On this page8
学完这节你会After this lesson you can
  • 说清 useEffect 的清理函数什么时候跑、为什么必须有Explain when the useEffect cleanup function runs, and why it has to be there
  • 解释「过期闭包」为什么让 setSeconds(seconds + 1) 卡在 1Explain why a stale closure, a callback holding an old value, freezes setSeconds(seconds + 1) at 1
  • 独立实现 start / pause / reset 的计时器Build a timer with start / pause / reset on your own
  • 看懂「忘了清理」造成的两种后果:越跳越快、卸载后泄漏Recognise the two results of a missing cleanup: the count speeds up, and the timer keeps running after the component is gone
这在考试里考什么What the exam does with this

源项目里没有任何定时器,所以前面的课没讲过清理函数 —— 但它是 useEffect 的另一半,同类考试(计时器、轮询、订阅、事件监听、WebSocket)几乎必考。这道题是这个知识点最短的载体。The source projects contain no timers, so no earlier lesson covered the cleanup function. It is the other half of useEffect, and exams of this kind almost always ask for it: timers, polling, subscriptions, event listeners, WebSocket. This question is the shortest way to carry that one idea.

§01

清理函数:effect 的另一半The cleanup function: the other half of an effect

useEffect 里 return 出去的那个函数,React 会在「下一次执行之前」和「卸载时」调用它。React calls the function you return from useEffect twice over: before the next run, and when the component is removed.

前面讲 useEffect 时只讲了「什么时候跑」。 完整的规则是四句话

  1. 首次渲染后,执行 effect。
  2. 依赖变化时,先执行上一次的清理函数,再执行新的 effect。
  3. 组件卸载时,执行最后一次的清理函数。
  4. 没有 return,就没有清理这一步。

所以清理函数的职责很明确:把这一次 effect 建立起来的东西拆掉。建了定时器就清定时器,加了事件监听就移除监听, 开了订阅就取消订阅,发了请求就中止请求。

判别口诀:effect 里只要出现了setInterval / setTimeout /addEventListener / subscribe /new WebSocket / fetch, 就一定要有 return。

Earlier lessons on useEffect only covered when it runs. The full rule is four sentences:

  1. After the first render, run the effect.
  2. When a dependency changes, run the previous cleanup first, then run the new effect.
  3. When the component unmounts, run the last cleanup.
  4. No return means no cleanup step at all.

So the cleanup has a precise job: tear down whatever this run of the effect set up. Started an interval, clear the interval; added a listener, remove the listener; opened a subscription, cancel it; fired a request, abort it.

Quick test: if an effect contains setInterval / setTimeout / addEventListener / subscribe / new WebSocket / fetch, it needs a return.

TSX计时器的核心九行The nine lines at the heart of the timer已跑通Verified
1useEffect(() => {
2 if (!running) return; // 没在跑就不建定时器
3
4 const id = setInterval(() => {
5 setSeconds((s) => s + 1);
6 }, 1000);
7
8 return () => clearInterval(id); // ← 这一行是整道题的答案
9}, [running]);
1useEffect(() => {
2 if (!running) return; // not running, so build no interval
3
4 const id = setInterval(() => {
5 setSeconds((s) => s + 1);
6 }, 1000);
7
8 return () => clearInterval(id); // ← this line is the answer to the whole task
9}, [running]);
§02

为什么必须用 setSeconds(s => s + 1)Why setSeconds(s => s + 1) is required

写成 setSeconds(seconds + 1) 会卡在 1 不动。这个坑叫「过期闭包」。Write setSeconds(seconds + 1) and the display freezes at 1. The name for this is a stale closure: the callback still holds an old value.

setInterval 的回调是在某一次渲染里创建的。它通过闭包捕获了那一次渲染的 seconds

依赖是 [running],所以 seconds 变化不会重建 effect,那个回调也就永远不会被替换。 于是它每一秒都在算「当时那个 seconds + 1」:

函数式更新绕开了这个问题setSeconds(s => s + 1) 里的 s是 React 在调用时交给你的最新值,不来自闭包。

另一条常见但更差的解法是把seconds 加进依赖数组。那样每一秒都会 销毁定时器再建一个新的 —— 能跑,但计时会因为反复重建而漂移, 而且完全没必要。

The setInterval callback is created inside one particular render. Through its closure it captured that render’s seconds.

The dependency list is [running], so a change to seconds does not rebuild the effect, and that callback never gets replaced. Every second it computes “that old seconds + 1”:

The updater form sidesteps the whole thing: the s in setSeconds(s => s + 1) is the latest value React hands you at call time, not something out of the closure.

The other common but worse fix is adding seconds to the dependency array. Then every second destroys the interval and builds a new one — it runs, but the clock drifts from all that rebuilding, and there is no reason for it.

TSX示意Illustrative
1// ✗ 过期闭包:seconds 永远是 effect 创建那一刻的值(0)
2useEffect(() => {
3 if (!running) return;
4 const id = setInterval(() => {
5 setSeconds(seconds + 1); // 0 + 1 = 1,每秒都算出 1
6 }, 1000);
7 return () => clearInterval(id);
8}, [running]);
9// 显示:00:01 然后一动不动
10
11// ✓ 函数式更新:s 是 React 给的最新值
12setSeconds((s) => s + 1);
1// ✗ Stale closure: seconds stays the value it had when the effect was created (0)
2useEffect(() => {
3 if (!running) return;
4 const id = setInterval(() => {
5 setSeconds(seconds + 1); // 0 + 1 = 1, so every second computes 1
6 }, 1000);
7 return () => clearInterval(id);
8}, [running]);
9// Display: 00:01 and then nothing moves
10
11// ✓ Updater form: s is the latest value React gives you
12setSeconds((s) => s + 1);
§03

忘了清理会怎样:两种后果,都实测过What a missing cleanup does: two results, both measured

我把 clearInterval 那行删掉真跑了一遍,8 个测试挂了 4 个。I deleted the clearInterval line and ran the suite for real: 4 of the 8 tests failed.

后果一:秒数越跳越快。每次 running 变成 true 就新建一个 interval,旧的没被清掉还在跑。start / pause 来回四次之后, 同时有 4 个 interval 在给同一个 state 加一。

测试里那条「start/pause 四次,每次 1 秒,应该正好 4 秒」 就是专门抓它的 —— 漏了清理会得到1 + 2 + 3 + 4 = 10 秒。这是实测输出:

后果二:组件卸载后定时器还在跑。这是内存泄漏,而且回调还会对已经卸载的组件调用 setState。 测试用 vi.getTimerCount() 直接查: 卸载后应该是 0,漏了清理就是 1。

Consequence one: the seconds speed up. Every time running flips to true a new interval is created, and the old one was never cleared, so it is still running. After four start / pause rounds, four intervals are adding one to the same state.

The test that says “start/pause four times, one second each, should land on exactly 4 seconds” is there to catch this — miss the cleanup and you get 1 + 2 + 3 + 4 = 10 seconds. This is the real output:

Consequence two: the interval keeps running after unmount. That is a memory leak, and the callback also calls setState on a component that is already gone. The test checks it directly with vi.getTimerCount(): it should be 0 after unmount, and it is 1 when the cleanup is missing.

Terminal本机实测:漏掉清理函数的后果Measured here: what a missing cleanup costs已跑通Verified
1# 把 return () => clearInterval(id) 删掉之后的真实输出
2$ npx vitest run src/Timer.test.tsx
3
4pause stops the clock and keeps the value
5 Expected element to have text content: 00:02
6 Received: 00:07
7
8start/pause many times does not speed up(清理函数生效的证据)
9 Expected element to have text content: 00:04
10 Received: 00:101+2+3+4
11
12reset stops and zeroes
13 Expected element to have text content: 00:00
14 Received: 00:03
15
16unmount clears the interval(不再有活着的定时器)
17 AssertionError: expected 1 to be +0 // Object.is equality
18 - Expected 0
19 + Received 1
20
21 Tests 4 failed | 4 passed (8)
1# The real output after deleting return () => clearInterval(id)
2$ npx vitest run src/Timer.test.tsx
3
4pause stops the clock and keeps the value
5 Expected element to have text content: 00:02
6 Received: 00:07
7
8start/pause many times does not speed up(清理函数生效的证据)
9 Expected element to have text content: 00:04
10 Received: 00:101+2+3+4
11
12reset stops and zeroes
13 Expected element to have text content: 00:00
14 Received: 00:03
15
16unmount clears the interval(不再有活着的定时器)
17 AssertionError: expected 1 to be +0 // Object.is equality
18 - Expected 0
19 + Received 1
20
21 Tests 4 failed | 4 passed (8)
§04

完整答案The complete answer

8 个测试全过,其中两条专门验证清理生效。All 8 tests pass, and two of them exist only to prove the cleanup ran.

format 单独导出成纯函数,方便直接单测 —— 「把能纯化的逻辑抽出来」在 assessment 里是加分项。

reset 同时把 running 设回 false —— 否则清零之后它会立刻从 0 继续跑,不符合「重置」的预期。

format is exported on its own as a pure function so it can be unit-tested directly — “pull out the logic that can be pure” scores points in an assessment.

reset also sets running back to false — otherwise it zeroes the clock and immediately starts counting from 0 again, which is not what “reset” means.

TSXsrc/components/Timer/index.tsx(实测 8/8 通过)src/components/Timer/index.tsx (8 of 8 pass here)已跑通Verified
1import React, { useEffect, useState } from "react";
2
3const pad = (n: number) => String(n).padStart(2, "0");
4export const format = (totalSeconds: number) =>
5 `${pad(Math.floor(totalSeconds / 60))}:${pad(totalSeconds % 60)}`;
6
7const Timer: React.FC = () => {
8 const [seconds, setSeconds] = useState(0);
9 const [running, setRunning] = useState(false);
10
11 useEffect(() => {
12 if (!running) return; // 没在跑就不建定时器
13
14 const id = setInterval(() => {
15 // 必须用函数式更新。这个回调是在 effect 那一次渲染里创建的,
16 // 写成 setSeconds(seconds + 1) 会永远读到当时那个 seconds(过期闭包),
17 // 于是秒数卡在 1 不动。
18 setSeconds((s) => s + 1);
19 }, 1000);
20
21 // 清理函数:running 变化时、以及组件卸载时都会跑。
22 // 少了它 -> 每次 running 变 true 就多一个 interval,秒数越跳越快;
23 // 组件卸载后 interval 还在跑 -> 内存泄漏。
24 return () => clearInterval(id);
25 }, [running]);
26
27 const reset = () => {
28 setRunning(false);
29 setSeconds(0);
30 };
31
32 return (
33 <div data-testid="timer">
34 <output data-testid="display">{format(seconds)}</output>
35 <button onClick={() => setRunning((r) => !r)} data-testid="toggle">
36 {running ? "Pause" : "Start"}
37 </button>
38 <button onClick={reset} data-testid="reset">
39 Reset
40 </button>
41 </div>
42 );
43};
44
45export default Timer;
1import React, { useEffect, useState } from "react";
2
3const pad = (n: number) => String(n).padStart(2, "0");
4export const format = (totalSeconds: number) =>
5 `${pad(Math.floor(totalSeconds / 60))}:${pad(totalSeconds % 60)}`;
6
7const Timer: React.FC = () => {
8 const [seconds, setSeconds] = useState(0);
9 const [running, setRunning] = useState(false);
10
11 useEffect(() => {
12 if (!running) return; // not running, so build no interval
13
14 const id = setInterval(() => {
15 // The updater form is required. This callback was created in one render of
16 // the effect, so setSeconds(seconds + 1) would always read that render's
17 // seconds (a stale closure) and the count would freeze at 1.
18 setSeconds((s) => s + 1);
19 }, 1000);
20
21 // Cleanup: runs when running changes, and again when the component unmounts.
22 // Without it -> every switch to true adds one more interval and the count
23 // speeds up; after unmount the interval runs on -> a memory leak.
24 return () => clearInterval(id);
25 }, [running]);
26
27 const reset = () => {
28 setRunning(false);
29 setSeconds(0);
30 };
31
32 return (
33 <div data-testid="timer">
34 <output data-testid="display">{format(seconds)}</output>
35 <button onClick={() => setRunning((r) => !r)} data-testid="toggle">
36 {running ? "Pause" : "Start"}
37 </button>
38 <button onClick={reset} data-testid="reset">
39 Reset
40 </button>
41 </div>
42 );
43};
44
45export default Timer;
§05

怎么验证How to check it

定时器怎么测?把时间也 mock 掉。How do you test a timer? Replace the clock with a fake one you control.

测计时器不能真等 3 秒。vi.useFakeTimers()setInterval 换成假的,vi.advanceTimersByTime(3000)一瞬间把时钟推 3 秒 —— 测试跑得快,而且结果稳定。

三个关键写法值得单独记:

  • 推时间必须包在 act() 里, 否则 React 的 state 更新还没落到 DOM,断言会读到旧值。
  • vi.getTimerCount() 直接查「现在还有几个定时器活着」 —— 这是验证清理函数最直接的手段,比看秒数更硬。
  • afterEach(() => vi.useRealTimers())必须有,否则假时钟会漏到别的测试文件里。

start/pause many times does not speed up 那一条 就是上面「1+2+3+4 = 10」的来源。

You cannot really wait 3 seconds in a test. vi.useFakeTimers() swaps setInterval for a fake one, and vi.advanceTimersByTime(3000) pushes the clock forward 3 seconds in an instant — fast tests, stable results.

Three details worth memorising on their own:

  • Advancing time must be wrapped in act(), or React’s state update has not landed in the DOM yet and the assertion reads the old value.
  • vi.getTimerCount() answers “how many timers are alive right now” — the most direct way to verify a cleanup, harder evidence than reading the seconds.
  • afterEach(() => vi.useRealTimers()) is mandatory, otherwise the fake clock leaks into other test files.

start/pause many times does not speed up is where the “1+2+3+4 = 10” above comes from.

Terminal验证命令The command that verifies it已跑通Verified
1npx vitest run src/Timer.test.tsx # 8 passed
TSXsrc/Timer.test.tsx(DrillLab 自出,本机跑过)src/Timer.test.tsx (written for DrillLab, run here)已跑通Verified
1import { act, render, screen, fireEvent } from "@testing-library/react";
2import { afterEach, beforeEach, expect, test, vi } from "vitest";
3import Timer, { format } from "./components/Timer";
4
5beforeEach(() => vi.useFakeTimers());
6afterEach(() => vi.useRealTimers());
7
8const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms));
9
10test("formats seconds as mm:ss", () => {
11 expect(format(0)).toBe("00:00");
12 expect(format(9)).toBe("00:09");
13 expect(format(65)).toBe("01:05");
14 expect(format(600)).toBe("10:00");
15});
16
17test("does not tick before start", () => {
18 render(<Timer />);
19 advance(5000);
20 expect(screen.getByTestId("display")).toHaveTextContent("00:00");
21});
22
23test("counts up once per second while running", () => {
24 render(<Timer />);
25 fireEvent.click(screen.getByTestId("toggle"));
26 advance(3000);
27 expect(screen.getByTestId("display")).toHaveTextContent("00:03");
28});
29
30test("pause stops the clock and keeps the value", () => {
31 render(<Timer />);
32 fireEvent.click(screen.getByTestId("toggle"));
33 advance(2000);
34 fireEvent.click(screen.getByTestId("toggle"));
35 advance(5000);
36 expect(screen.getByTestId("display")).toHaveTextContent("00:02");
37});
38
39test("start/pause many times does not speed up(清理函数生效的证据)", () => {
40 render(<Timer />);
41 for (let i = 0; i < 4; i++) {
42 fireEvent.click(screen.getByTestId("toggle")); // start
43 advance(1000);
44 fireEvent.click(screen.getByTestId("toggle")); // pause
45 }
46 // 四轮每轮 1 秒 -> 正好 4 秒。若忘了 clearInterval,会变成 1+2+3+4=10 秒
47 expect(screen.getByTestId("display")).toHaveTextContent("00:04");
48});
49
50test("reset stops and zeroes", () => {
51 render(<Timer />);
52 fireEvent.click(screen.getByTestId("toggle"));
53 advance(3000);
54 fireEvent.click(screen.getByTestId("reset"));
55 expect(screen.getByTestId("display")).toHaveTextContent("00:00");
56 expect(screen.getByTestId("toggle")).toHaveTextContent("Start");
57 advance(3000);
58 expect(screen.getByTestId("display")).toHaveTextContent("00:00");
59});
60
61test("crosses the minute boundary", () => {
62 render(<Timer />);
63 fireEvent.click(screen.getByTestId("toggle"));
64 advance(61000);
65 expect(screen.getByTestId("display")).toHaveTextContent("01:01");
66});
67
68test("unmount clears the interval(不再有活着的定时器)", () => {
69 const { unmount } = render(<Timer />);
70 fireEvent.click(screen.getByTestId("toggle"));
71 advance(1000);
72 unmount();
73 expect(vi.getTimerCount()).toBe(0);
74});
1import { act, render, screen, fireEvent } from "@testing-library/react";
2import { afterEach, beforeEach, expect, test, vi } from "vitest";
3import Timer, { format } from "./components/Timer";
4
5beforeEach(() => vi.useFakeTimers());
6afterEach(() => vi.useRealTimers());
7
8const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms));
9
10test("formats seconds as mm:ss", () => {
11 expect(format(0)).toBe("00:00");
12 expect(format(9)).toBe("00:09");
13 expect(format(65)).toBe("01:05");
14 expect(format(600)).toBe("10:00");
15});
16
17test("does not tick before start", () => {
18 render(<Timer />);
19 advance(5000);
20 expect(screen.getByTestId("display")).toHaveTextContent("00:00");
21});
22
23test("counts up once per second while running", () => {
24 render(<Timer />);
25 fireEvent.click(screen.getByTestId("toggle"));
26 advance(3000);
27 expect(screen.getByTestId("display")).toHaveTextContent("00:03");
28});
29
30test("pause stops the clock and keeps the value", () => {
31 render(<Timer />);
32 fireEvent.click(screen.getByTestId("toggle"));
33 advance(2000);
34 fireEvent.click(screen.getByTestId("toggle"));
35 advance(5000);
36 expect(screen.getByTestId("display")).toHaveTextContent("00:02");
37});
38
39test("start/pause many times does not speed up(清理函数生效的证据)", () => {
40 render(<Timer />);
41 for (let i = 0; i < 4; i++) {
42 fireEvent.click(screen.getByTestId("toggle")); // start
43 advance(1000);
44 fireEvent.click(screen.getByTestId("toggle")); // pause
45 }
46 // Four rounds of 1 second each -> exactly 4. Without clearInterval: 1+2+3+4=10
47 expect(screen.getByTestId("display")).toHaveTextContent("00:04");
48});
49
50test("reset stops and zeroes", () => {
51 render(<Timer />);
52 fireEvent.click(screen.getByTestId("toggle"));
53 advance(3000);
54 fireEvent.click(screen.getByTestId("reset"));
55 expect(screen.getByTestId("display")).toHaveTextContent("00:00");
56 expect(screen.getByTestId("toggle")).toHaveTextContent("Start");
57 advance(3000);
58 expect(screen.getByTestId("display")).toHaveTextContent("00:00");
59});
60
61test("crosses the minute boundary", () => {
62 render(<Timer />);
63 fireEvent.click(screen.getByTestId("toggle"));
64 advance(61000);
65 expect(screen.getByTestId("display")).toHaveTextContent("01:01");
66});
67
68test("unmount clears the interval(不再有活着的定时器)", () => {
69 const { unmount } = render(<Timer />);
70 fireEvent.click(screen.getByTestId("toggle"));
71 advance(1000);
72 unmount();
73 expect(vi.getTimerCount()).toBe(0);
74});
练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L2填空Fill the blanks补全计时器的 effectFill in the effect of the timerDrillLab 自出Written by DrillLab

三个空,全在这九行里。第 2 个空漏了会「越跳越快」, 第 3 个空写错会「卡在 1 不动」。

Three blanks, all within these nine lines. Miss the second and the clock speeds up with every start. Get the third wrong and the display freezes at 1.

TSXsrc/components/Timer/index.tsx3 个空3 blanks
1useEffect(() => {
2 if (!running) return;
3
4 const id = setInterval(() => {
5 setSeconds();
6 }, 1000);
7
8 return () => (id);
9}, []);
把 3 个空都填上才能检查(还差 3 个)Fill all 3 blanks to check (3 to go)
L3写整块Write a block自己写出整个计时器Write the whole timer yourselfDrillLab 自出Written by DrillLab

两个 state、一个 effect、一个 reset、一个 mm:ss 格式化。 检查器会专门查清理函数和函数式更新。

Two states, one effect, one reset, and one mm:ss formatter. The checker looks specifically for the cleanup function and the updater form.

要求Requirements
  • format(65) 要返回 "01:05",个位数补零format(65) has to return "01:05", padding single digits with a zero
  • 点 Start 开始每秒加一,点 Pause 停下并保留当前值Start begins adding one per second; Pause stops and keeps the current value
  • Reset 停下来并清零(按钮文字回到 Start)Reset stops and goes back to zero (the button text returns to Start)
  • effect 必须返回清理函数清掉定时器The effect has to return a cleanup function that clears the interval
  • 必须用函数式更新,避免过期闭包Use the updater form, so there is no stale closure
  • 按钮文字:跑着显示 Pause,停着显示 StartButton text: Pause while running, Start while stopped
TSXsrc/components/Timer/index.tsx
This check is textual: it looks for the right constructs, it does not run your code
提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.

L2Debug LabDebug LabDebug Lab · 计时器越跑越快Debug Lab · the timer keeps getting fasterDrillLab 自出Written by DrillLab

点了几次 Start / Pause 之后,秒数开始一次跳好几秒。 下面是真实的测试输出。

After a few clicks of Start and Pause, the seconds start jumping several at a time. Below is the real test output.

第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
$ npx vitest run src/Timer.test.tsx ✕ pause stops the clock and keeps the value Expected element to have text content: 00:02 Received: 00:07 ✕ start/pause many times does not speed up Expected element to have text content: 00:04 Received: 00:10 ✕ reset stops and zeroes Expected element to have text content: 00:00 Received: 00:03 ✕ unmount clears the interval AssertionError: expected 1 to be +0 // Object.is equality Tests 4 failed | 4 passed (8) # 现象:start / pause 来回点四次,每次只走 1 秒, # 显示却是 00:10 —— 正好是 1+2+3+4。 # 而且 Reset 之后秒数还在自己往上涨。$ npx vitest run src/Timer.test.tsx ✕ pause stops the clock and keeps the value Expected element to have text content: 00:02 Received: 00:07 ✕ start/pause many times does not speed up Expected element to have text content: 00:04 Received: 00:10 ✕ reset stops and zeroes Expected element to have text content: 00:00 Received: 00:03 ✕ unmount clears the interval AssertionError: expected 1 to be +0 // Object.is equality Tests 4 failed | 4 passed (8) # Symptom: click start / pause four times, one second of running each time, # and the display reads 00:10 — exactly 1+2+3+4. # After Reset the seconds also keep climbing on their own.
TSXsrc/components/Timer/index.tsx示意Illustrative
1useEffect(() => {
2 if (!running) return;
3
4 const id = setInterval(() => {
5 setSeconds((s) => s + 1);
6 }, 1000);
7}, [running]);
第 2 步 · 这是什么类型的错误Step 2 · what kind of error is this
错例Wrong

初学者常见的几种写法错误Mistakes beginners actually make

下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.

TSX示意Illustrative
1// ✗ 过期闭包:显示卡在 00:01
2const id = setInterval(() => {
3 setSeconds(seconds + 1);
4}, 1000);
1// ✗ Stale closure: the display freezes at 00:01
2const id = setInterval(() => {
3 setSeconds(seconds + 1);
4}, 1000);
回调闭包捕获的 seconds 是 effect 创建那一刻的值。 依赖里没有 seconds,effect 不会重建, 所以它每秒都在算 0 + 1改成 setSeconds(s => s + 1)The callback captured the value seconds had at the moment the effect was created. seconds is not in the dependency list, so the effect is never rebuilt, and every second it computes 0 + 1 again. Change it to setSeconds(s => s + 1).
TSX示意Illustrative
1// ✗ reset 只清零,没停表
2const reset = () => setSeconds(0);
1// ✗ reset only zeroes the count; it never stops the clock
2const reset = () => setSeconds(0);
running 还是 true,定时器还在跑 —— 清零之后立刻又从 0 开始涨。用户点「重置」的预期是停下来并归零,两件事都要做。running is still true and the interval is still going, so the count starts climbing again from 0 right away. When a user presses reset they expect it to stop and go back to zero. Both things have to happen.
TSX示意Illustrative
1// ✗ 用 state 存定时器 id
2const [timerId, setTimerId] = useState<number | null>(null);
3const start = () => setTimerId(setInterval(...));
4const pause = () => { if (timerId) clearInterval(timerId); };
1// ✗ Keeping the interval id in a state
2const [timerId, setTimerId] = useState<number | null>(null);
3const start = () => setTimerId(setInterval(...));
4const pause = () => { if (timerId) clearInterval(timerId); };
能跑,但把「副作用的生命周期」从 React 手里拿走了自己管, 卸载时很容易漏清理。
定时器 id 不是渲染要用的数据,本来就不该是 state (真要存也该用 useRef)。让 effect + 清理函数管,代码更短也更难写错。
This works, but it takes the lifetime of the side effect out of React’s hands and into yours, and then it is easy to forget the cleanup when the component is removed.
The timer id is not data the render needs, so it should not be state in the first place (if you must keep it, use useRef). Let the effect and its cleanup function handle it: less code, and fewer ways to get it wrong.
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

effect 里出现 setInterval / setTimeoutsetInterval or setTimeout inside an effect
return () => clear…return () => clear…
effect 里 addEventListeneraddEventListener inside an effect
return () => removeEventListener(同一个函数引用)return () => removeEventListener, with the same function reference
effect 里 subscribe / new WebSocketsubscribe or new WebSocket inside an effect
return () => unsubscribe / closereturn () => unsubscribe / close
定时器回调里要用到 stateA timer callback needs to read state
函数式更新,别读闭包里的值Use the updater function form; do not read the captured value
「数值卡在第一次的结果不动」A number is stuck on the result of the first run
过期闭包A stale closure
「越跑越快」「重复触发」It speeds up, or fires more than once
漏了清理函数The cleanup function is missing
这节的要点What to take away
  1. 清理函数在「依赖变化前」和「卸载时」执行 —— 它负责拆掉这次 effect 建立的东西。The cleanup function runs before the dependencies change and when the component is removed. Its job is to take down whatever this effect set up.
  2. effect 里出现定时器/监听器/订阅/连接/请求,就一定要 return。If an effect starts a timer, a listener, a subscription, a connection, or a request, it must return a cleanup function.
  3. 定时器回调必须用函数式更新,否则闭包里的 state 永远是旧的。A timer callback has to use the updater function form, or the state it captured stays old forever.
  4. 漏掉 clearInterval 的实测后果:start/pause 四次得到 10 秒而不是 4 秒,卸载后定时器还活着。Measured result of a missing clearInterval: four start/pause cycles give 10 seconds instead of 4, and the timer is still alive after the component is gone.
  5. reset 要同时停表和清零;定时器 id 不该放 state。reset has to stop the clock and zero it. The timer id does not belong in state.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises3 个,就在这一页上面 —— 别攒着最后一起做3 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson变式三 · fetch 取数:loading、error 与竞态Variation 3 · fetching data: loading, error, and the race between two requests
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 变式一 · Todo ListVariation 1 · Todo List