DrillLab
第 15 / 21 节LESSON 15 / 21约 14 分钟~14 min

变式一 · Todo ListVariation 1 · Todo List

和 Notes Manager 同一套骨架,多了一个布尔字段、一个筛选、两个批量操作。The same skeleton as the Notes Manager, plus one boolean field, one filter, and two bulk actions.

2 个练习2 exercisesReact · 第 5 部分React · Part 5
这一页有什么On this page9
学完这节你会After this lesson you can
  • 用 map + 对象展开就地翻转一条数据的布尔字段Flip the boolean field on one item using map plus object spread
  • 把「剩余几项」「是否全部完成」「筛选后的列表」都写成派生数据Write how many are left, whether everything is done, and the filtered list as derived values
  • 实现全选 / 取消全选和「清除已完成」Implement select all / clear all, and clear completed
  • 说清筛选态下的删除为什么必须作用于原始数据Explain why a delete under an active filter must act on the original data
这在考试里考什么What the exam does with this

Todo List 是 React 面试与 assessment 出现频率最高的一道题。它考的东西和真实 Q1 完全重合(受控输入、三种不可变更新、派生数据),只是多了 toggle 和 filter 两个变式。做完这道题,Q1 那类题就不会再有陌生感。The Todo List is the question that shows up most often in React interviews and exams. What it tests overlaps completely with the real Q1: controlled inputs, the three ways to update data without changing the original, and derived values. It only adds two extra moves, toggle and filter. Once you have done this one, Q1 style questions no longer feel new.

§01

数据形状:只比 Note 多一个布尔字段The shape of the data: one boolean field more than Note

先看类型,其余都是从它推出来的。Start with the type. Everything else follows from it.

Note 对比一下:Todo 多了一个done: boolean,于是多出「翻转」这个操作;Filter 是个字面量联合,用来存筛选条件。

注意 Filter 不属于任何一条 todo —— 它是「界面当前怎么看这份数据」,所以它是独立的一个 state, 而不是 todo 上的字段。这个区分在很多人那里是模糊的。

Put it next to Note: Todo adds exactly one field, done: boolean, and that one field buys you a new operation — toggling. Filter is a literal union that holds the current filter.

Notice that Filter belongs to no single todo — it is “how the UI is looking at this data right now”, so it is its own piece of state, not a field on a todo. Plenty of people are fuzzy about that line.

TypeScriptsrc/types/Todo.ts已跑通Verified
1export type Filter = "all" | "active" | "done";
2
3export type Todo = {
4 id: number;
5 text: string;
6 done: boolean;
7};
§02

翻转一条:map + 对象展开Flipping one item: map plus object spread

这是三件套之外的第四个动作,但底层还是 map。This is a fourth action beyond the usual three, but underneath it is still map.

Q1 的「就地更新」是整条替换note.id === x ? submittedNote : note)。 这里只想改一个字段,所以用对象展开 + 覆盖

{ ...t, done: !t.done } 造了一个新对象: 旧字段全部照抄,只把 done 换成反过来的值。

为什么不能直接 t.done = !t.done?那是在改原对象。虽然数组是新的(map 返回新数组), 但里面那个 todo 对象还是同一个引用 —— 如果哪天你给列表项加了 React.memo, 它会认为「props 没变」而不重渲染,于是勾选框不动。不可变更新要一路到底,不能只做外层。

The “update in place” in Q1 replaced the whole item (note.id === x ? submittedNote : note). Here you only want to change one field, so you use object spread plus an override:

{ ...t, done: !t.done } builds a new object: copy every old field, then swap done for its opposite.

Why not just t.done = !t.done? Because that mutates the original object. The array is new (map returns a new array), but the todo object inside is still the same reference — the day you wrap list items in React.memo, it decides “props did not change”, skips the re-render, and the checkbox stops moving. Immutable updates have to go all the way down, not just the outer layer.

TSX两种 map 更新Two kinds of map update已跑通Verified
1// 翻转一条
2setTodos((prev) =>
3 prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
4);
5
6// 对比 Q1 的整条替换
7setNotes((prev) =>
8 prev.map((n) => (n.id === next.id ? next : n)),
9);
1// Toggle one item
2setTodos((prev) =>
3 prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
4);
5
6// Compare with the whole-item replace of Q1
7setNotes((prev) =>
8 prev.map((n) => (n.id === next.id ? next : n)),
9);
TSX看起来像不可变,其实不是It looks immutable, and it is not示意Illustrative
1// ✗ 数组是新的,但对象被就地改了
2setTodos((prev) =>
3 prev.map((t) => {
4 if (t.id === id) t.done = !t.done; // 改的是原对象
5 return t;
6 }),
7);
1// ✗ The array is new, but the object was changed in place
2setTodos((prev) =>
3 prev.map((t) => {
4 if (t.id === id) t.done = !t.done; // this changes the original object
5 return t;
6 }),
7);
§03

三个派生数据,一个 state 都不加Three derived values, and not one new state

这道题最容易过度设计的地方是「剩余几项」和「筛选后的列表」—— 很多人会为它们各开一个 useState, 再用 useEffect 同步。全都不需要。

visible / remaining /allDone 三个值都能从 todosfilter 当场算出来,每次渲染重算,永远不会不一致。

关键陷阱:所有写操作都要作用于 todos, 不是 visible筛选到「已完成」时点删除,如果你写setTodos(visible.filter(...)), 那些被筛掉的未完成项会全部消失。这一条在模拟考 A 里也考过, 是同一个坑。

The easiest place to over-engineer this question is “how many left” and “the filtered list” — a lot of people give each one its own useState and then sync them with a useEffect. None of that is needed.

visible / remaining / allDone can all be computed on the spot from todos and filter, recomputed on every render, so they can never fall out of sync.

The trap that matters: every write goes through todos, not visible. Filter down to “done” and hit delete, and if you wrote setTodos(visible.filter(...)), every unfinished item that got filtered out disappears. Mock exam A tests the same trap.

TSX派生数据Derived values已跑通Verified
1const visible =
2 filter === "all"
3 ? todos
4 : todos.filter((t) => (filter === "done" ? t.done : !t.done));
5const remaining = todos.filter((t) => !t.done).length;
6const allDone = todos.length > 0 && remaining === 0;
7
8// 写操作一律作用于 todos
9const remove = (id: number) => {
10 setTodos((prev) => prev.filter((t) => t.id !== id));
11};
1const visible =
2 filter === "all"
3 ? todos
4 : todos.filter((t) => (filter === "done" ? t.done : !t.done));
5const remaining = todos.filter((t) => !t.done).length;
6const allDone = todos.length > 0 && remaining === 0;
7
8// Every write acts on todos
9const remove = (id: number) => {
10 setTodos((prev) => prev.filter((t) => t.id !== id));
11};
allDone 里那个 todos.length > 0 不能省 —— 空列表时 remaining 也是 0,不判断的话按钮一上来就显示「Uncheck all」。The todos.length > 0 inside allDone is not optional: with an empty list remaining is also 0, and without that check the button says Uncheck all from the very first render.
§04

两个批量操作The two bulk actions

全选 / 取消全选的正确语义是 「以当前是否已全部完成为准,整体反转」—— 而不是「每一条各自翻转」。后者在混合状态下会得到一半勾一半不勾, 不符合用户预期。

清除已完成就是一次 filter, 和删除单条是同一个动作,只是条件不同。

Check all / uncheck all means “flip everything to one target value, decided by whether they are all done right now” — not “flip each item on its own”. The second reading leaves a mixed list half checked and half unchecked, which is not what the user expects.

Clear completed is one filter call. Same action as deleting a single item, different condition.

TSX批量操作The bulk actions已跑通Verified
1const toggleAll = () => {
2 const next = !allDone; // 先定一个统一的目标值
3 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
4};
5
6const clearDone = () => {
7 setTodos((prev) => prev.filter((t) => !t.done));
8};
1const toggleAll = () => {
2 const next = !allDone; // decide one shared target value first
3 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
4};
5
6const clearDone = () => {
7 setTodos((prev) => prev.filter((t) => !t.done));
8};
TSX示意Illustrative
1// ✗ 每条各自翻转 —— 混合状态下变成「反选」,不是「全选」
2setTodos((prev) => prev.map((t) => ({ ...t, done: !t.done })));
1// ✗ Toggling each item on its own — with a mixed list this inverts the selection instead of selecting all
2setTodos((prev) => prev.map((t) => ({ ...t, done: !t.done })));
§05

完整答案The complete answer

7 个测试全过。All 7 tests pass.

注意 data-testid 用了模板字符串生成 (`filter-${f}`)—— 三个筛选按钮共用一段渲染代码, 这是列表渲染的常见写法。

Note that data-testid is generated with a template string (`filter-${f}`) — the three filter buttons share one piece of render code, which is the normal way to render a list.

TSXsrc/components/TodoList/index.tsx(实测 7/7 通过)src/components/TodoList/index.tsx (7 of 7 pass here)已跑通Verified
1import React, { useState } from "react";
2import type { Filter, Todo } from "../../types/Todo";
3
4const TodoList: React.FC = () => {
5 const [todos, setTodos] = useState<Todo[]>([]);
6 const [text, setText] = useState("");
7 const [filter, setFilter] = useState<Filter>("all");
8
9 // 三个派生数据:都能从 todos 算出来,都不该做成 state
10 const visible =
11 filter === "all"
12 ? todos
13 : todos.filter((t) => (filter === "done" ? t.done : !t.done));
14 const remaining = todos.filter((t) => !t.done).length;
15 const allDone = todos.length > 0 && remaining === 0;
16
17 const isInvalid = text.trim() === "";
18
19 const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
20 event.preventDefault();
21 if (isInvalid) return;
22 setTodos((prev) => [...prev, { id: Date.now(), text: text.trim(), done: false }]);
23 setText("");
24 };
25
26 // 就地翻转一条:map + 对象展开,不改原对象
27 const toggle = (id: number) => {
28 setTodos((prev) =>
29 prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
30 );
31 };
32
33 const remove = (id: number) => {
34 setTodos((prev) => prev.filter((t) => t.id !== id));
35 };
36
37 // 全选/全不选:以「当前是否已全部完成」为准整体反转
38 const toggleAll = () => {
39 const next = !allDone;
40 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
41 };
42
43 const clearDone = () => {
44 setTodos((prev) => prev.filter((t) => !t.done));
45 };
46
47 return (
48 <div data-testid="todo-app">
49 <form onSubmit={handleSubmit} data-testid="todo-form">
50 <input
51 type="text"
52 value={text}
53 onChange={(e) => setText(e.target.value)}
54 data-testid="todo-input"
55 />
56 <button type="submit" disabled={isInvalid} data-testid="todo-submit">
57 Add
58 </button>
59 </form>
60
61 <div>
62 <button onClick={toggleAll} data-testid="toggle-all">
63 {allDone ? "Uncheck all" : "Check all"}
64 </button>
65 <button onClick={clearDone} data-testid="clear-done">
66 Clear completed
67 </button>
68 <span data-testid="remaining">{remaining} left</span>
69 </div>
70
71 <div>
72 {(["all", "active", "done"] as Filter[]).map((f) => (
73 <button key={f} onClick={() => setFilter(f)} data-testid={`filter-${f}`}
74 aria-pressed={filter === f}>
75 {f}
76 </button>
77 ))}
78 </div>
79
80 <ul data-testid="todo-list">
81 {visible.map((todo) => (
82 <li key={todo.id} data-done={todo.done}>
83 <input
84 type="checkbox"
85 checked={todo.done}
86 onChange={() => toggle(todo.id)}
87 aria-label={`toggle ${todo.text}`}
88 />
89 <span>{todo.text}</span>
90 <button onClick={() => remove(todo.id)} aria-label={`delete ${todo.text}`}>
91 Delete
92 </button>
93 </li>
94 ))}
95 </ul>
96 </div>
97 );
98};
99
100export default TodoList;
1import React, { useState } from "react";
2import type { Filter, Todo } from "../../types/Todo";
3
4const TodoList: React.FC = () => {
5 const [todos, setTodos] = useState<Todo[]>([]);
6 const [text, setText] = useState("");
7 const [filter, setFilter] = useState<Filter>("all");
8
9 // Three derived values: each computed from todos, none of them a state
10 const visible =
11 filter === "all"
12 ? todos
13 : todos.filter((t) => (filter === "done" ? t.done : !t.done));
14 const remaining = todos.filter((t) => !t.done).length;
15 const allDone = todos.length > 0 && remaining === 0;
16
17 const isInvalid = text.trim() === "";
18
19 const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
20 event.preventDefault();
21 if (isInvalid) return;
22 setTodos((prev) => [...prev, { id: Date.now(), text: text.trim(), done: false }]);
23 setText("");
24 };
25
26 // Toggle one item in place: map plus an object spread, original untouched
27 const toggle = (id: number) => {
28 setTodos((prev) =>
29 prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
30 );
31 };
32
33 const remove = (id: number) => {
34 setTodos((prev) => prev.filter((t) => t.id !== id));
35 };
36
37 // Check all / uncheck all: one shared target value from whether all are done
38 const toggleAll = () => {
39 const next = !allDone;
40 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
41 };
42
43 const clearDone = () => {
44 setTodos((prev) => prev.filter((t) => !t.done));
45 };
46
47 return (
48 <div data-testid="todo-app">
49 <form onSubmit={handleSubmit} data-testid="todo-form">
50 <input
51 type="text"
52 value={text}
53 onChange={(e) => setText(e.target.value)}
54 data-testid="todo-input"
55 />
56 <button type="submit" disabled={isInvalid} data-testid="todo-submit">
57 Add
58 </button>
59 </form>
60
61 <div>
62 <button onClick={toggleAll} data-testid="toggle-all">
63 {allDone ? "Uncheck all" : "Check all"}
64 </button>
65 <button onClick={clearDone} data-testid="clear-done">
66 Clear completed
67 </button>
68 <span data-testid="remaining">{remaining} left</span>
69 </div>
70
71 <div>
72 {(["all", "active", "done"] as Filter[]).map((f) => (
73 <button key={f} onClick={() => setFilter(f)} data-testid={`filter-${f}`}
74 aria-pressed={filter === f}>
75 {f}
76 </button>
77 ))}
78 </div>
79
80 <ul data-testid="todo-list">
81 {visible.map((todo) => (
82 <li key={todo.id} data-done={todo.done}>
83 <input
84 type="checkbox"
85 checked={todo.done}
86 onChange={() => toggle(todo.id)}
87 aria-label={`toggle ${todo.text}`}
88 />
89 <span>{todo.text}</span>
90 <button onClick={() => remove(todo.id)} aria-label={`delete ${todo.text}`}>
91 Delete
92 </button>
93 </li>
94 ))}
95 </ul>
96 </div>
97 );
98};
99
100export default TodoList;
§06

怎么验证How to check it

这就是跑出 7/7 的那个测试文件,原样贴在这里。This is the test file that produced 7 of 7, pasted exactly as it is.

想真正练这道题,就在一个空的 Vite + React + TS 项目里 (react-notes-app 的脚手架直接能用)新建src/types/Todo.tssrc/components/TodoList/index.tsx, 把下面这个测试文件放到 src/TodoList.test.tsx, 然后自己把组件写出来。

注意测试是按 data-testidaria-label 找元素的 ——这些名字必须和测试对上, 这也是真实 assessment 的规矩(Q1 的六个 testid 一个都不能改)。

第 5 条 filters without losing data是专门抓「筛选态下改坏了底层数据」的:筛完再切回all,两条都得还在。

To really practise this one, open an empty Vite + React + TS project (the react-notes-app scaffold works as is), create src/types/Todo.ts and src/components/TodoList/index.tsx, drop the test file below into src/TodoList.test.tsx, and write the component yourself.

The tests find elements by data-testid and aria-labelthose names have to match the tests, which is the rule in the real assessment too (you cannot rename a single one of the six testids in Q1).

Test 5, filters without losing data, exists to catch “the filter broke the underlying data”: filter, switch back to all, and both items must still be there.

Terminal验证命令已跑通Verified
1npx vitest run src/TodoList.test.tsx # 7 passed
TSXsrc/TodoList.test.tsx(DrillLab 自出,本机跑过)src/TodoList.test.tsx (written for DrillLab, run here)已跑通Verified
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import TodoList from "./components/TodoList";
4
5const add = async (text: string) => {
6 await userEvent.type(screen.getByTestId("todo-input"), text);
7 await userEvent.click(screen.getByTestId("todo-submit"));
8};
9
10test("adds a todo and clears the input", async () => {
11 render(<TodoList />);
12 await add("买牛奶");
13 expect(screen.getByTestId("todo-list")).toHaveTextContent("买牛奶");
14 expect(screen.getByTestId("todo-input")).toHaveValue("");
15});
16
17test("submit disabled when input is only whitespace", async () => {
18 render(<TodoList />);
19 expect(screen.getByTestId("todo-submit")).toBeDisabled();
20 await userEvent.type(screen.getByTestId("todo-input"), " ");
21 expect(screen.getByTestId("todo-submit")).toBeDisabled();
22});
23
24test("toggles one todo without touching the others", async () => {
25 render(<TodoList />);
26 await add("A");
27 await add("B");
28 await userEvent.click(screen.getByLabelText("toggle A"));
29
30 const items = screen.getByTestId("todo-list").querySelectorAll("li");
31 expect(items[0].getAttribute("data-done")).toBe("true");
32 expect(items[1].getAttribute("data-done")).toBe("false");
33});
34
35test("remaining count is derived, not stored", async () => {
36 render(<TodoList />);
37 await add("A");
38 await add("B");
39 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
40 await userEvent.click(screen.getByLabelText("toggle A"));
41 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
42});
43
44test("filters without losing data", async () => {
45 render(<TodoList />);
46 await add("A");
47 await add("B");
48 await userEvent.click(screen.getByLabelText("toggle A"));
49
50 await userEvent.click(screen.getByTestId("filter-active"));
51 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("A");
52 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
53
54 await userEvent.click(screen.getByTestId("filter-done"));
55 expect(screen.getByTestId("todo-list")).toHaveTextContent("A");
56 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("B");
57
58 // 切回 all,两条都还在 —— 筛选不能动底层数据
59 await userEvent.click(screen.getByTestId("filter-all"));
60 expect(screen.getByTestId("todo-list")).toHaveTextContent("A");
61 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
62});
63
64test("toggle all then uncheck all", async () => {
65 render(<TodoList />);
66 await add("A");
67 await add("B");
68 await userEvent.click(screen.getByTestId("toggle-all"));
69 expect(screen.getByTestId("remaining")).toHaveTextContent("0 left");
70 await userEvent.click(screen.getByTestId("toggle-all"));
71 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
72});
73
74test("clear completed removes only done todos", async () => {
75 render(<TodoList />);
76 await add("A");
77 await add("B");
78 await userEvent.click(screen.getByLabelText("toggle A"));
79 await userEvent.click(screen.getByTestId("clear-done"));
80
81 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("A");
82 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
83});
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import TodoList from "./components/TodoList";
4
5const add = async (text: string) => {
6 await userEvent.type(screen.getByTestId("todo-input"), text);
7 await userEvent.click(screen.getByTestId("todo-submit"));
8};
9
10test("adds a todo and clears the input", async () => {
11 render(<TodoList />);
12 await add("买牛奶");
13 expect(screen.getByTestId("todo-list")).toHaveTextContent("买牛奶");
14 expect(screen.getByTestId("todo-input")).toHaveValue("");
15});
16
17test("submit disabled when input is only whitespace", async () => {
18 render(<TodoList />);
19 expect(screen.getByTestId("todo-submit")).toBeDisabled();
20 await userEvent.type(screen.getByTestId("todo-input"), " ");
21 expect(screen.getByTestId("todo-submit")).toBeDisabled();
22});
23
24test("toggles one todo without touching the others", async () => {
25 render(<TodoList />);
26 await add("A");
27 await add("B");
28 await userEvent.click(screen.getByLabelText("toggle A"));
29
30 const items = screen.getByTestId("todo-list").querySelectorAll("li");
31 expect(items[0].getAttribute("data-done")).toBe("true");
32 expect(items[1].getAttribute("data-done")).toBe("false");
33});
34
35test("remaining count is derived, not stored", async () => {
36 render(<TodoList />);
37 await add("A");
38 await add("B");
39 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
40 await userEvent.click(screen.getByLabelText("toggle A"));
41 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
42});
43
44test("filters without losing data", async () => {
45 render(<TodoList />);
46 await add("A");
47 await add("B");
48 await userEvent.click(screen.getByLabelText("toggle A"));
49
50 await userEvent.click(screen.getByTestId("filter-active"));
51 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("A");
52 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
53
54 await userEvent.click(screen.getByTestId("filter-done"));
55 expect(screen.getByTestId("todo-list")).toHaveTextContent("A");
56 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("B");
57
58 // Back to all and both are still there: filtering must not touch the data
59 await userEvent.click(screen.getByTestId("filter-all"));
60 expect(screen.getByTestId("todo-list")).toHaveTextContent("A");
61 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
62});
63
64test("toggle all then uncheck all", async () => {
65 render(<TodoList />);
66 await add("A");
67 await add("B");
68 await userEvent.click(screen.getByTestId("toggle-all"));
69 expect(screen.getByTestId("remaining")).toHaveTextContent("0 left");
70 await userEvent.click(screen.getByTestId("toggle-all"));
71 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
72});
73
74test("clear completed removes only done todos", async () => {
75 render(<TodoList />);
76 await add("A");
77 await add("B");
78 await userEvent.click(screen.getByLabelText("toggle A"));
79 await userEvent.click(screen.getByTestId("clear-done"));
80
81 expect(screen.getByTestId("todo-list")).not.toHaveTextContent("A");
82 expect(screen.getByTestId("todo-list")).toHaveTextContent("B");
83});
练习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补全翻转与批量操作Fill in the toggle and the bulk actionDrillLab 自出Written by DrillLab

四个空。第 2 个是「只改一个字段」的写法,第 4 个考的是 「全选」和「反选」的区别。

Four blanks. The second is how you change one field only. The fourth is about the difference between select-all and invert-selection.

TSXsrc/components/TodoList/index.tsx4 个空4 blanks
1// 翻转一条:只改 done,其他字段照抄
2const toggle = (id: number) => {
3 setTodos((prev) =>
4 prev.((t) => (t.id === id ? { , done: !t.done } : t)),
5 );
6};
7
8// 剩余几项 —— 派生数据
9const remaining = todos.((t) => !t.done).length;
10
11// 全选 / 取消全选
12const toggleAll = () => {
13 const next = ;
14 setTodos((prev) => prev.map((t) => ({ ...t, done: next })));
15};
把 4 个空都填上才能检查(还差 4 个)Fill all 4 blanks to check (4 to go)
L3写整块Write a block自己写出筛选与「清除已完成」Write the filtering and the clear-completed action yourselfDrillLab 自出Written by DrillLab

已有 todosfilter 两个 state。 写出可见列表和「清除已完成」,注意筛选态下的写操作该作用于谁。

You already have the two states todos and filter. Write the visible list and the clear-completed action, and think about which list a write should act on while a filter is on.

要求Requirements
  • visible 是派生数据,不许用 useState 或 useEffectvisible is derived data: no useState and no useEffect
  • filter 为 "all" 时显示全部,"active" 显示未完成,"done" 显示已完成When filter is "all" show everything, "active" shows the unfinished ones, "done" shows the finished ones
  • clearDone 移除所有已完成项,保留未完成项clearDone removes every finished item and keeps the unfinished ones
  • 写操作必须作用于 todos,不能作用于 visibleA write has to act on todos, never on visible
  • 不许修改原数组Do not change the original array
TSXsrc/components/TodoList/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.

错例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// ✗ 为筛选结果单开一个 state + useEffect 同步
2const [visible, setVisible] = useState<Todo[]>([]);
3useEffect(() => {
4 setVisible(filter === "all" ? todos : todos.filter(...));
5}, [todos, filter]);
1// ✗ A separate state for the filtered result, kept in sync by useEffect
2const [visible, setVisible] = useState<Todo[]>([]);
3useEffect(() => {
4 setVisible(filter === "all" ? todos : todos.filter(...));
5}, [todos, filter]);
同一个事实存了两份,还多了一次渲染。而且只要有一处忘了触发同步 就会不一致。能算出来的别存。The same fact is now stored twice, and there is one extra render. Miss a single place that should trigger the sync and the two copies disagree. If you can compute it, do not store it.
TSX示意Illustrative
1// ✗ 筛选态下基于 visible 删除
2const remove = (id: number) => {
3 setTodos(visible.filter((t) => t.id !== id));
4};
1// ✗ Deleting from visible while a filter is on
2const remove = (id: number) => {
3 setTodos(visible.filter((t) => t.id !== id));
4};
筛选到「已完成」时,visible 里只有已完成项。 这一行会把所有未完成项一起丢掉
写操作永远作用于完整数据。
When the filter is set to done, visible holds only the finished items. This line throws away every unfinished item as well.
A write always acts on the full data.
迁移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.

「翻转某一项的开关」Flip the switch on one item
map + { ...item, flag: !item.flag }map + { ...item, flag: !item.flag }
「显示剩余 N 项」Show how many items are left
派生数据,filter().lengthA derived value: filter().length
「全选 / 全不选」Select all / clear all
先算统一目标值,再整体套上去Compute one shared target value, then apply it to every item
有筛选又有增删改A filter plus add, delete, and edit
读用 visible,写一律用完整数据Read from visible, always write to the full data
这节的要点What to take away
  1. Todo 比 Note 只多一个布尔字段,于是多出「翻转」这个动作。Todo has exactly one field more than Note, a boolean, and that field adds the toggle action.
  2. 翻转用 map + 对象展开;不可变要一路到底,不能只换外层数组。Toggle with map plus object spread. The new object has to go all the way down, not stop at the outer array.
  3. visible / remaining / allDone 三个都是派生数据,一个 state 都不加。visible, remaining, and allDone are all derived values. None of them needs its own state.
  4. 全选是「统一目标值」,不是「各自翻转」,否则变成反选。Select all means one shared target value, not flipping each item on its own. Flipping each item inverts the selection instead.
  5. 筛选态下的写操作必须作用于完整数据,否则会丢掉被筛掉的项。Under an active filter a write must act on the full data, or the filtered-out items are lost.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson变式二 · 计时器:useEffect 的清理函数Variation 2 · a timer: the useEffect cleanup function
    下一节Next lesson
  3. 可选:再巩固一下Optional: reinforce it对应的 Coding 题:The coding problem: Todo List
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 实现:worker pool(工人池)Building it: a worker pool, meaning a fixed number of workers sharing one queue