DrillLab
第 19 / 25 节LESSON 19 / 25约 24 分钟~24 min

缺口三 · 同一个 Todo 换成 Redux ToolkitGap 3 · the same Todo app, moved to Redux Toolkit

业务和变式一完全一样,换成 createSlice + selector —— 正好能对比出 Redux 到底多给了什么。The same app as variant one, rebuilt with createSlice and selectors — which makes it easy to see what Redux actually adds.

1 个练习1 exercises面试 · 第 7 部分Interview · Part 7
这一页有什么On this page6
学完这节你会After this lesson you can
  • 用 createSlice 写出一个完整的 slice,并说明 Immer 为什么不违反「state 只读」Write a complete slice with createSlice, and explain why Immer does not break the rule that state is read-only
  • 解释 prepare 的作用以及为什么 id 不能在 reducer 里生成Explain what prepare is for, and why an id must not be generated inside a reducer
  • 用 selector 做到「只订阅自己要的那部分」Use a selector so a component subscribes only to the part it needs
  • 脱离 React 单测 reducerUnit test a reducer without React
这在考试里考什么What the exam does with this

「用 Redux Toolkit 做一个 Todo」是 Medium 里的常见题。它真正在考三件事:知不知道现在不该手写 action types 了、知不知道 Immer 的草稿是怎么回事、知不知道 selector 的意义。同一个业务和变式一对照着看,能清楚看出 Redux 换来了什么、代价是什么。Building a Todo app with Redux Toolkit is a common medium problem. It really tests three things: whether you know that action types are no longer written by hand, whether you know what an Immer draft is, and whether you know what a selector is for. Putting it next to variant one, which does the same job, shows clearly what Redux buys you and what it costs.

§01

createSlice:一次生成 reducer 和 actionscreateSlice: one call gives you the reducer and the actions

手写 Redux 的那套样板已经过时了。The hand-written Redux boilerplate is out of date.

老写法要写三份: action types 常量、 action creators、 一个大 switch 的 reducer。createSlice把三份合成一份—— 你只写 reducers 对象, 它自动生成对应的 action creator 和 type (type 是 "todos/added"这样的 切片名/reducer 名)。

Immer 那一点必须说清state.items.push(...)看起来违反了「state 只读」, 其实你拿到的是一个草稿代理(draft proxy)—— 你的修改被记录下来, Immer 最终产出一个新对象, 原 state 一个字节没动。测试里我用expect(next).not.toBe(empty)和「原 state 长度仍是 0」两条断言证明了这一点。

但有个边界要记住: 这个「能 mutate」的特权只在 createSlice /createReducer 里成立。 在组件里、在 selector 里、 在自己写的普通函数里,该守的不可变规则一条都不能少

prepare 是这道题的加分点。生成 id 要用nanoid(), 而 reducer 必须是纯函数 —— 不能出现随机数和时间(否则同样的 state + action 会得到不同结果,时间旅行就废了)。prepare让你在创建 action 的时候生成 id,reducer 只负责把它放进去。

The old way needed three pieces: action type constants, action creators, and a reducer built around one big switch. createSlice folds the three into one — you only write the reducers object and it generates the matching action creator and type (the type is "todos/added", that is, sliceName/reducerName).

The Immer part has to be said clearly: state.items.push(...) looks like it breaks “state is read-only”, but what you hold is a draft proxy — your edits get recorded and Immer produces a new object at the end, leaving the original state untouched byte for byte. The test proves it with two assertions: expect(next).not.toBe(empty) and “the original state still has length 0”.

But there is a boundary to remember: this mutate-anyway privilege only holds inside createSlice / createReducer. In components, in selectors, in your own plain functions, every immutability rule still applies.

prepare is the bonus point of this question. Generating the id needs nanoid(), and a reducer has to be pure — no random numbers, no clock (otherwise the same state + action gives different results and time travel is dead). prepare lets you generate the id while the action is being created, so the reducer only puts it in place.

TypeScriptsrc/store/todosSlice.ts(实测 8/8 通过)src/store/todosSlice.ts (8/8 in a real run)已跑通Verified
1import { createSlice, nanoid } from "@reduxjs/toolkit";
2import type { PayloadAction } from "@reduxjs/toolkit";
3
4export type Filter = "all" | "active" | "done";
5export interface Todo { id: string; text: string; done: boolean }
6export interface TodosState { items: Todo[]; filter: Filter }
7
8const initialState: TodosState = { items: [], filter: "all" };
9
10const todosSlice = createSlice({
11 name: "todos",
12 initialState,
13 reducers: {
14 // 看着像在改 state,其实 RTK 内置的 Immer 给的是草稿代理,
15 // 最终产出的是新对象 —— 所以并不违反「state 只读」这条原则。
16 added: {
17 // prepare 让 action creator 只收 text,id 在这里生成 —— 这样
18 // reducer 里就不会出现 nanoid(),reducer 保持纯函数。
19 reducer(state, action: PayloadAction<Todo>) {
20 state.items.push(action.payload);
21 },
22 prepare(text: string) {
23 return { payload: { id: nanoid(), text: text.trim(), done: false } };
24 },
25 },
26 toggled(state, action: PayloadAction<string>) {
27 const t = state.items.find((x) => x.id === action.payload);
28 if (t) t.done = !t.done;
29 },
30 removed(state, action: PayloadAction<string>) {
31 state.items = state.items.filter((x) => x.id !== action.payload);
32 },
33 clearedDone(state) {
34 state.items = state.items.filter((x) => !x.done);
35 },
36 filterChanged(state, action: PayloadAction<Filter>) {
37 state.filter = action.payload;
38 },
39 },
40});
41
42export const { added, toggled, removed, clearedDone, filterChanged } = todosSlice.actions;
43export default todosSlice.reducer;
44
45/* ---------- selectors:组件只订阅它真正要用的那部分 ---------- */
46
47export const selectFilter = (s: { todos: TodosState }) => s.todos.filter;
48export const selectRemaining = (s: { todos: TodosState }) =>
49 s.todos.items.filter((t) => !t.done).length;
50
51export const selectVisible = (s: { todos: TodosState }) => {
52 const { items, filter } = s.todos;
53 if (filter === "all") return items;
54 return items.filter((t) => (filter === "done" ? t.done : !t.done));
55};
1import { createSlice, nanoid } from "@reduxjs/toolkit";
2import type { PayloadAction } from "@reduxjs/toolkit";
3
4export type Filter = "all" | "active" | "done";
5export interface Todo { id: string; text: string; done: boolean }
6export interface TodosState { items: Todo[]; filter: Filter }
7
8const initialState: TodosState = { items: [], filter: "all" };
9
10const todosSlice = createSlice({
11 name: "todos",
12 initialState,
13 reducers: {
14 // It looks like state is being changed, but the Immer built into RTK hands you
15 // a draft proxy and produces a new object — so "state is read-only" still holds.
16 added: {
17 // prepare lets the action creator take only text and build the id here — that way
18 // nanoid() never appears in the reducer, and the reducer stays a pure function.
19 reducer(state, action: PayloadAction<Todo>) {
20 state.items.push(action.payload);
21 },
22 prepare(text: string) {
23 return { payload: { id: nanoid(), text: text.trim(), done: false } };
24 },
25 },
26 toggled(state, action: PayloadAction<string>) {
27 const t = state.items.find((x) => x.id === action.payload);
28 if (t) t.done = !t.done;
29 },
30 removed(state, action: PayloadAction<string>) {
31 state.items = state.items.filter((x) => x.id !== action.payload);
32 },
33 clearedDone(state) {
34 state.items = state.items.filter((x) => !x.done);
35 },
36 filterChanged(state, action: PayloadAction<Filter>) {
37 state.filter = action.payload;
38 },
39 },
40});
41
42export const { added, toggled, removed, clearedDone, filterChanged } = todosSlice.actions;
43export default todosSlice.reducer;
44
45/* ---------- selectors: a component subscribes only to the part it really uses ---------- */
46
47export const selectFilter = (s: { todos: TodosState }) => s.todos.filter;
48export const selectRemaining = (s: { todos: TodosState }) =>
49 s.todos.items.filter((t) => !t.done).length;
50
51export const selectVisible = (s: { todos: TodosState }) => {
52 const { items, filter } = s.todos;
53 if (filter === "all") return items;
54 return items.filter((t) => (filter === "done" ? t.done : !t.done));
55};
TypeScriptsrc/store/index.ts已跑通Verified
1import { configureStore } from "@reduxjs/toolkit";
2import todos from "./todosSlice";
3
4// configureStore 默认就装好了 thunk 和 DevTools,不用自己 applyMiddleware
5export const makeStore = () => configureStore({ reducer: { todos } });
6
7export type AppStore = ReturnType<typeof makeStore>;
8export type RootState = ReturnType<AppStore["getState"]>;
9export type AppDispatch = AppStore["dispatch"];
1import { configureStore } from "@reduxjs/toolkit";
2import todos from "./todosSlice";
3
4// configureStore already sets up thunk and DevTools; no applyMiddleware needed
5export const makeStore = () => configureStore({ reducer: { todos } });
6
7export type AppStore = ReturnType<typeof makeStore>;
8export type RootState = ReturnType<AppStore["getState"]>;
9export type AppDispatch = AppStore["dispatch"];
configureStore 默认就装好了 thunk 和 DevTools —— 不用再手写 applyMiddleware(thunk) 和那段 window.__REDUX_DEVTOOLS_EXTENSION__ 判断。configureStore already sets up thunk and DevTools, so you no longer write applyMiddleware(thunk) by hand, nor that window.__REDUX_DEVTOOLS_EXTENSION__ check.
§02

selector:这才是 Redux 比 Context 强的地方Selectors: this is where Redux beats Context

三个 useSelector 各自订阅一小块。Three useSelector calls, each subscribing to one small part.

组件里写了三个 useSelector: 可见列表、剩余条数、当前筛选。每一个只在自己那部分变化时触发重渲染。

对比 Context(变式五那道题): context value 一变,所有 useTheme()的组件全部重渲染, 哪怕它只用了 theme而变的是 toggleTheme这就是八股 #349 里说的「Context 缺精细订阅」, 在这里能具体看到。

一个必须知道的坑: selector 不要返回新对象。useSelector默认用 === 比较结果, 返回 { a, b }这样的新对象每次都不相等, 于是每次 store 有任何变化都重渲染 —— 等于优化白做。
解法:拆成多个useSelector(本实现的做法,最简单), 或者用 createSelector做记忆化,或者传shallowEqual 当第二个参数。

注意 selectVisible是从 items 派生的, 筛选不改底层数据—— 和变式一里「写操作必须作用于完整数据」 是同一条规矩。测试里专门有一条验证「筛完底层仍是两条」。

The component has three useSelector calls: the visible list, the remaining count, the current filter. Each one re-renders only when its own slice changes.

Compare with Context (the variant 5 question): the moment the context value changes, every useTheme() component re-renders, even one that only uses theme while what changed was toggleTheme. That is the “Context has no fine-grained subscription” point from interview #349, in concrete form.

One trap you have to know: a selector must not return a new object. useSelector compares its result with === by default, and a fresh object like { a, b } is never equal, so any change anywhere in the store re-renders — the optimization cancels itself out.
Fixes: split it into several useSelector calls (what this implementation does, and the simplest), memoize with createSelector, or pass shallowEqual as the second argument.

Note that selectVisible is derived from items, and filtering does not touch the underlying data — the same rule as “writes have to act on the complete data” in variant 1. One test is there to verify that after filtering the underlying data still has two entries.

TSXsrc/components/TodoApp/index.tsx(实测通过)src/components/TodoApp/index.tsx (passes in a real run)已跑通Verified
1import React, { useState } from "react";
2import { useDispatch, useSelector } from "react-redux";
3import {
4 added,
5 clearedDone,
6 filterChanged,
7 removed,
8 selectFilter,
9 selectRemaining,
10 selectVisible,
11 toggled,
12 type Filter,
13} from "../../store/todosSlice";
14
15const TodoApp: React.FC = () => {
16 const dispatch = useDispatch();
17 // 三个 selector 各自订阅一小块:只有这一块变了才重渲染,
18 // 这正是 Context 做不到的(context 一变所有消费者都重渲染)
19 const visible = useSelector(selectVisible);
20 const remaining = useSelector(selectRemaining);
21 const filter = useSelector(selectFilter);
22
23 const [text, setText] = useState("");
24 const invalid = text.trim() === "";
25
26 return (
27 <div data-testid="todo-app">
28 <form
29 onSubmit={(e) => {
30 e.preventDefault();
31 if (invalid) return;
32 dispatch(added(text)); // prepare 里生成 id
33 setText("");
34 }}
35 >
36 <input value={text} onChange={(e) => setText(e.target.value)} data-testid="input" />
37 <button type="submit" disabled={invalid} data-testid="submit">Add</button>
38 </form>
39
40 <span data-testid="remaining">{remaining} left</span>
41 <button onClick={() => dispatch(clearedDone())} data-testid="clear-done">Clear done</button>
42
43 {(["all", "active", "done"] as Filter[]).map((f) => (
44 <button
45 key={f}
46 onClick={() => dispatch(filterChanged(f))}
47 aria-pressed={filter === f}
48 data-testid={`filter-${f}`}
49 >
50 {f}
51 </button>
52 ))}
53
54 <ul data-testid="list">
55 {visible.map((t) => (
56 <li key={t.id} data-done={t.done}>
57 <input
58 type="checkbox"
59 checked={t.done}
60 onChange={() => dispatch(toggled(t.id))}
61 aria-label={`toggle ${t.text}`}
62 />
63 <span>{t.text}</span>
64 <button onClick={() => dispatch(removed(t.id))} aria-label={`delete ${t.text}`}>
65 Delete
66 </button>
67 </li>
68 ))}
69 </ul>
70 </div>
71 );
72};
73
74export default TodoApp;
1import React, { useState } from "react";
2import { useDispatch, useSelector } from "react-redux";
3import {
4 added,
5 clearedDone,
6 filterChanged,
7 removed,
8 selectFilter,
9 selectRemaining,
10 selectVisible,
11 toggled,
12 type Filter,
13} from "../../store/todosSlice";
14
15const TodoApp: React.FC = () => {
16 const dispatch = useDispatch();
17 // Three selectors, each subscribing to one small part: a re-render happens only when
18 // that part changes. Context cannot do this (one context change re-renders every consumer)
19 const visible = useSelector(selectVisible);
20 const remaining = useSelector(selectRemaining);
21 const filter = useSelector(selectFilter);
22
23 const [text, setText] = useState("");
24 const invalid = text.trim() === "";
25
26 return (
27 <div data-testid="todo-app">
28 <form
29 onSubmit={(e) => {
30 e.preventDefault();
31 if (invalid) return;
32 dispatch(added(text)); // the id is built in prepare
33 setText("");
34 }}
35 >
36 <input value={text} onChange={(e) => setText(e.target.value)} data-testid="input" />
37 <button type="submit" disabled={invalid} data-testid="submit">Add</button>
38 </form>
39
40 <span data-testid="remaining">{remaining} left</span>
41 <button onClick={() => dispatch(clearedDone())} data-testid="clear-done">Clear done</button>
42
43 {(["all", "active", "done"] as Filter[]).map((f) => (
44 <button
45 key={f}
46 onClick={() => dispatch(filterChanged(f))}
47 aria-pressed={filter === f}
48 data-testid={`filter-${f}`}
49 >
50 {f}
51 </button>
52 ))}
53
54 <ul data-testid="list">
55 {visible.map((t) => (
56 <li key={t.id} data-done={t.done}>
57 <input
58 type="checkbox"
59 checked={t.done}
60 onChange={() => dispatch(toggled(t.id))}
61 aria-label={`toggle ${t.text}`}
62 />
63 <span>{t.text}</span>
64 <button onClick={() => dispatch(removed(t.id))} aria-label={`delete ${t.text}`}>
65 Delete
66 </button>
67 </li>
68 ))}
69 </ul>
70 </div>
71 );
72};
73
74export default TodoApp;
TSXuseSelector 最常见的性能坑The most common performance trap with useSelector示意Illustrative
1// ✗ selector 返回新对象:每次 store 变都重渲染
2const { visible, remaining } = useSelector((s) => ({
3 visible: selectVisible(s),
4 remaining: selectRemaining(s),
5}));
6
7// ✓ 拆开,各自比较各自的值
8const visible = useSelector(selectVisible);
9const remaining = useSelector(selectRemaining);
1// ✗ the selector returns a new object: every store change re-renders
2const { visible, remaining } = useSelector((s) => ({
3 visible: selectVisible(s),
4 remaining: selectRemaining(s),
5}));
6
7// ✓ split them, so each one is compared against its own value
8const visible = useSelector(selectVisible);
9const remaining = useSelector(selectRemaining);
§03

和变式一(useState 版)对比:换来了什么,代价是什么Compared with variant one, the useState version: what you gain and what it costs

变式一(useState)本题(Redux Toolkit)
状态放哪组件内全局 store
谁能改这个组件任何组件 dispatch 就行
逻辑能不能脱离 React 测不能(要 render)—— reducer 是纯函数
调试console.logDevTools 能看每个 action 和 state diff
代码量一个文件slice + store + 组件,三个文件
依赖两个包

结论怎么说(面试里问「该不该上 Redux」时用):这道题的规模用 Redux 是过度设计—— 一个组件自己的列表,useState 就够。Redux 的价值要在 「同一份状态被很多不相关的组件读写」 或者「需要按 action 追溯 bug」 的时候才兑现。
能主动说出「这题我会用 useState, 但如果需求是 XX 我会上 Redux」, 比闷头写完 Redux 更能显示判断力。

顺带看得出的一个真实收益: 因为 reducer 是纯函数,八条测试里有五条完全不需要渲染任何组件—— 直接 reducer(state, action) 断言。 测试跑得更快、失败信息更准。

Variant 1 (useState)This one (Redux Toolkit)
Where the state livesinside the componenta global store
Who can change itthis componentany component that dispatches
Can the logic be tested without Reactno (you have to render)yes — the reducer is a pure function
Debuggingconsole.logDevTools shows every action and the state diff
Amount of codeone fileslice + store + component, three files
Dependenciesnonetwo packages

How to phrase the conclusion (for when they ask “should we bring in Redux?”): at this size Redux is over-engineering — one component’s own list is fine with useState. Redux pays off when the same state is read and written by many unrelated components, or when you need to trace a bug action by action.
Saying “I would use useState here, but if the requirement were XX I would reach for Redux” shows more judgment than silently writing the whole Redux setup.

One real benefit that falls out of this: because the reducer is a pure function, five of the eight tests render no component at all — they assert on reducer(state, action) directly. The tests run faster and the failure messages are more precise.

TSXsrc/Rtk.test.tsx(DrillLab 自出,本机跑过 8/8)src/Rtk.test.tsx (written by DrillLab, 8/8 locally)已跑通Verified
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Provider } from "react-redux";
4import { expect, test } from "vitest";
5import TodoApp from "./components/TodoApp";
6import { makeStore } from "./store";
7import reducer, {
8 added,
9 clearedDone,
10 filterChanged,
11 removed,
12 selectVisible,
13 toggled,
14 type TodosState,
15} from "./store/todosSlice";
16
17const empty: TodosState = { items: [], filter: "all" };
18
19/* ---------- reducer 是纯函数,可以脱离 React 单测 ---------- */
20
21test("[371] added 追加一条,且不改原 state(Immer 产出的是新对象)", () => {
22 const next = reducer(empty, added("买牛奶"));
23
24 expect(next.items).toHaveLength(1);
25 expect(next.items[0].text).toBe("买牛奶");
26 expect(next.items[0].done).toBe(false);
27 expect(empty.items).toHaveLength(0); // 原 state 没动
28 expect(next).not.toBe(empty); // 是新对象
29});
30
31test("[371] added 会 trim,且 id 在 prepare 里生成(reducer 保持纯)", () => {
32 const next = reducer(empty, added(" 写文档 "));
33 expect(next.items[0].text).toBe("写文档");
34 expect(next.items[0].id).toBeTruthy();
35
36 // 同一段文本两次 dispatch,id 不同 —— 说明 id 不是 reducer 算的
37 const a = added("x");
38 const b = added("x");
39 expect(a.payload.id).not.toBe(b.payload.id);
40});
41
42test("[371] toggled 只翻转命中的那条", () => {
43 let s = reducer(empty, added("A"));
44 s = reducer(s, added("B"));
45 const idA = s.items[0].id;
46
47 const next = reducer(s, toggled(idA));
48 expect(next.items[0].done).toBe(true);
49 expect(next.items[1].done).toBe(false);
50});
51
52test("[371] removed 按 id 删;clearedDone 只删已完成", () => {
53 let s = reducer(empty, added("A"));
54 s = reducer(s, added("B"));
55 const [a, b] = s.items;
56
57 expect(reducer(s, removed(a.id)).items.map((t) => t.text)).toEqual(["B"]);
58
59 s = reducer(s, toggled(b.id));
60 expect(reducer(s, clearedDone()).items.map((t) => t.text)).toEqual(["A"]);
61});
62
63test("[371] selectVisible 按 filter 派生,不改底层数据", () => {
64 let s = reducer(empty, added("A"));
65 s = reducer(s, added("B"));
66 s = reducer(s, toggled(s.items[0].id));
67
68 const withFilter = (f: Parameters<typeof filterChanged>[0]) => ({
69 todos: reducer(s, filterChanged(f)),
70 });
71
72 expect(selectVisible({ todos: s }).map((t) => t.text)).toEqual(["A", "B"]);
73 expect(selectVisible(withFilter("done")).map((t) => t.text)).toEqual(["A"]);
74 expect(selectVisible(withFilter("active")).map((t) => t.text)).toEqual(["B"]);
75
76 // 筛选只影响「看到什么」,底层 items 仍是两条
77 expect(s.items).toHaveLength(2);
78});
79
80/* ---------- 组件 + Provider 的集成测试 ---------- */
81
82const renderApp = () =>
83 render(
84 <Provider store={makeStore()}>
85 <TodoApp />
86 </Provider>,
87 );
88
89test("[371] 通过 UI 新增,列表和计数都跟着变", async () => {
90 renderApp();
91 expect(screen.getByTestId("submit")).toBeDisabled();
92
93 await userEvent.type(screen.getByTestId("input"), "买牛奶");
94 await userEvent.click(screen.getByTestId("submit"));
95
96 expect(screen.getByTestId("list")).toHaveTextContent("买牛奶");
97 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
98 expect(screen.getByTestId("input")).toHaveValue("");
99});
100
101test("[371] 勾选一条,remaining 减一", async () => {
102 renderApp();
103 await userEvent.type(screen.getByTestId("input"), "A");
104 await userEvent.click(screen.getByTestId("submit"));
105 await userEvent.type(screen.getByTestId("input"), "B");
106 await userEvent.click(screen.getByTestId("submit"));
107
108 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
109 await userEvent.click(screen.getByLabelText("toggle A"));
110 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
111});
112
113test("[371] 筛选不会丢数据,切回 all 两条都在", async () => {
114 renderApp();
115 for (const t of ["A", "B"]) {
116 await userEvent.type(screen.getByTestId("input"), t);
117 await userEvent.click(screen.getByTestId("submit"));
118 }
119 await userEvent.click(screen.getByLabelText("toggle A"));
120
121 await userEvent.click(screen.getByTestId("filter-done"));
122 expect(screen.getByTestId("list")).toHaveTextContent("A");
123 expect(screen.getByTestId("list")).not.toHaveTextContent("B");
124
125 await userEvent.click(screen.getByTestId("filter-all"));
126 expect(screen.getByTestId("list")).toHaveTextContent("A");
127 expect(screen.getByTestId("list")).toHaveTextContent("B");
128});
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Provider } from "react-redux";
4import { expect, test } from "vitest";
5import TodoApp from "./components/TodoApp";
6import { makeStore } from "./store";
7import reducer, {
8 added,
9 clearedDone,
10 filterChanged,
11 removed,
12 selectVisible,
13 toggled,
14 type TodosState,
15} from "./store/todosSlice";
16
17const empty: TodosState = { items: [], filter: "all" };
18
19/* ---------- a reducer is a pure function, so it can be unit-tested without React ---------- */
20
21test("[371] added appends one item and does not change the old state (Immer produces a new object)", () => {
22 const next = reducer(empty, added("买牛奶"));
23
24 expect(next.items).toHaveLength(1);
25 expect(next.items[0].text).toBe("买牛奶");
26 expect(next.items[0].done).toBe(false);
27 expect(empty.items).toHaveLength(0); // the old state was not touched
28 expect(next).not.toBe(empty); // it is a new object
29});
30
31test("[371] added trims, and the id is built in prepare (the reducer stays pure)", () => {
32 const next = reducer(empty, added(" 写文档 "));
33 expect(next.items[0].text).toBe("写文档");
34 expect(next.items[0].id).toBeTruthy();
35
36 // dispatch the same text twice and the ids differ — proof the reducer does not compute the id
37 const a = added("x");
38 const b = added("x");
39 expect(a.payload.id).not.toBe(b.payload.id);
40});
41
42test("[371] toggled flips only the item it matched", () => {
43 let s = reducer(empty, added("A"));
44 s = reducer(s, added("B"));
45 const idA = s.items[0].id;
46
47 const next = reducer(s, toggled(idA));
48 expect(next.items[0].done).toBe(true);
49 expect(next.items[1].done).toBe(false);
50});
51
52test("[371] removed deletes by id; clearedDone deletes only the finished ones", () => {
53 let s = reducer(empty, added("A"));
54 s = reducer(s, added("B"));
55 const [a, b] = s.items;
56
57 expect(reducer(s, removed(a.id)).items.map((t) => t.text)).toEqual(["B"]);
58
59 s = reducer(s, toggled(b.id));
60 expect(reducer(s, clearedDone()).items.map((t) => t.text)).toEqual(["A"]);
61});
62
63test("[371] selectVisible derives from filter and does not change the underlying data", () => {
64 let s = reducer(empty, added("A"));
65 s = reducer(s, added("B"));
66 s = reducer(s, toggled(s.items[0].id));
67
68 const withFilter = (f: Parameters<typeof filterChanged>[0]) => ({
69 todos: reducer(s, filterChanged(f)),
70 });
71
72 expect(selectVisible({ todos: s }).map((t) => t.text)).toEqual(["A", "B"]);
73 expect(selectVisible(withFilter("done")).map((t) => t.text)).toEqual(["A"]);
74 expect(selectVisible(withFilter("active")).map((t) => t.text)).toEqual(["B"]);
75
76 // filtering only affects what you see; the underlying items are still two
77 expect(s.items).toHaveLength(2);
78});
79
80/* ---------- integration test: the component plus the Provider ---------- */
81
82const renderApp = () =>
83 render(
84 <Provider store={makeStore()}>
85 <TodoApp />
86 </Provider>,
87 );
88
89test("[371] adding through the UI updates both the list and the count", async () => {
90 renderApp();
91 expect(screen.getByTestId("submit")).toBeDisabled();
92
93 await userEvent.type(screen.getByTestId("input"), "买牛奶");
94 await userEvent.click(screen.getByTestId("submit"));
95
96 expect(screen.getByTestId("list")).toHaveTextContent("买牛奶");
97 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
98 expect(screen.getByTestId("input")).toHaveValue("");
99});
100
101test("[371] ticking one item lowers remaining by one", async () => {
102 renderApp();
103 await userEvent.type(screen.getByTestId("input"), "A");
104 await userEvent.click(screen.getByTestId("submit"));
105 await userEvent.type(screen.getByTestId("input"), "B");
106 await userEvent.click(screen.getByTestId("submit"));
107
108 expect(screen.getByTestId("remaining")).toHaveTextContent("2 left");
109 await userEvent.click(screen.getByLabelText("toggle A"));
110 expect(screen.getByTestId("remaining")).toHaveTextContent("1 left");
111});
112
113test("[371] filtering loses no data; switch back to all and both items are there", async () => {
114 renderApp();
115 for (const t of ["A", "B"]) {
116 await userEvent.type(screen.getByTestId("input"), t);
117 await userEvent.click(screen.getByTestId("submit"));
118 }
119 await userEvent.click(screen.getByLabelText("toggle A"));
120
121 await userEvent.click(screen.getByTestId("filter-done"));
122 expect(screen.getByTestId("list")).toHaveTextContent("A");
123 expect(screen.getByTestId("list")).not.toHaveTextContent("B");
124
125 await userEvent.click(screen.getByTestId("filter-all"));
126 expect(screen.getByTestId("list")).toHaveTextContent("A");
127 expect(screen.getByTestId("list")).toHaveTextContent("B");
128});
Terminal验证命令The command used to check it已跑通Verified
1# 这一道要单独装依赖(本站另外几道复用 react-notes-app 的 node_modules)
2$ npm i @reduxjs/toolkit react-redux
3$ npx vitest run
4
5 Test Files 1 passed (1)
6 Tests 8 passed (8)
1# This problem installs its own dependencies (the other problems here reuse the node_modules of react-notes-app)
2$ npm i @reduxjs/toolkit react-redux
3$ npx vitest run
4
5 Test Files 1 passed (1)
6 Tests 8 passed (8)
练习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补全 createSliceFill in createSliceDrillLab 自出Written by DrillLab

四个空。第 3 个空是这道题的加分点, 第 4 个空写错会让 reducer 不纯。

Four blanks. The 3rd one is the bonus point of this question; get the 4th one wrong and the reducer is no longer pure.

TSsrc/store/todosSlice.ts4 个空4 blanks
1const todosSlice = ({
2 name: "todos",
3 initialState,
4 reducers: {
5 added: {
6 reducer(state, action: PayloadAction<Todo>) {
7 state.items.(action.payload);
8 },
9 (text: string) {
10 return { payload: { id: (), text: text.trim(), done: false } };
11 },
12 },
13 toggled(state, action: PayloadAction<string>) {
14 const t = state.items.find((x) => x.id === action.payload);
15 if (t) t.done = !t.done;
16 },
17 },
18});
把 4 个空都填上才能检查(还差 4 个)Fill all 4 blanks to check (4 to go)
错例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.

TypeScript示意Illustrative
1// ✗ 在 reducer 里生成 id
2added(state, action: PayloadAction<string>) {
3 state.items.push({ id: nanoid(), text: action.payload, done: false });
4}
1// ✗ building the id inside the reducer
2added(state, action: PayloadAction<string>) {
3 state.items.push({ id: nanoid(), text: action.payload, done: false });
4}
reducer 不再是纯函数: 同样的 state 和 action 会产出不同结果。
后果是时间旅行调试和 action 重放都失效—— 这正是 Redux 三大原则第 3 条要防的。prepare
The reducer is no longer pure: the same state and the same action produce a different result each time.
Time-travel debugging and replaying actions both stop working — which is exactly what the third Redux principle protects. Use prepare.
TSX示意Illustrative
1// ✗ selector 返回新对象
2const { visible, remaining } = useSelector((s) => ({
3 visible: selectVisible(s),
4 remaining: selectRemaining(s),
5}));
1// ✗ the selector returns a new object
2const { visible, remaining } = useSelector((s) => ({
3 visible: selectVisible(s),
4 remaining: selectRemaining(s),
5}));
useSelector=== 比结果, 新对象永远不相等 ——store 里任何东西变了这个组件都重渲染, selector 的意义完全没了。
拆成多个 useSelector, 或者用 createSelector /shallowEqual
useSelector compares the result with ===, and a new object is never equal to the previous one — so this component re-renders whenever anything in the store changes, and the selector no longer does anything for you.
Split it into several useSelector calls, or use createSelector or shallowEqual.
TypeScript示意Illustrative
1// ✗ 把 Immer 的特权带出 createSlice
2export function addTodo(state: TodosState, todo: Todo) {
3 state.items.push(todo); // 这里没有草稿代理,是真的改了原对象
4 return state;
5}
1// ✗ carrying the Immer privilege outside createSlice
2export function addTodo(state: TodosState, todo: Todo) {
3 state.items.push(todo); // there is no draft proxy here; this really changes the original object
4 return state;
5}
Immer 只在createSlice /createReducer 内部生效。在普通函数里这就是实实在在的 mutate, 而且返回的还是同一个引用 —— React 不会重渲染。Immer only applies inside createSlice and createReducer. In an ordinary function this changes the original object for real, and it returns the same reference — so React does not re-render.
迁移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.

要写 ReduxYou are asked to write Redux
createSlice,别手写 action typescreateSlice; do not write action types by hand
reducer 里想用 nanoid / Date.nowYou want nanoid or Date.now inside a reducer
挪到 prepare 或 action creatorMove it into prepare, or into the action creator
「加了 selector 还是每次都重渲染」"I added a selector and it still re-renders every time"
selector 返回了新对象The selector is returning a new object
问该不该上 ReduxAsked whether the app should use Redux
看是否多组件读写 + 是否需要按 action 追溯Ask whether several components read and write it, and whether you need to trace changes action by action
要脱离 React 测状态逻辑You need to test the state logic without React
reducer 是纯函数,直接 reducer(state, action)A reducer is a pure function; just call reducer(state, action)
这节的要点What to take away
  1. createSlice 一次生成 reducer、action creators 和 types,老写法的三份样板全省。One createSlice call produces the reducer, the action creators and the types, replacing all three pieces of the old boilerplate.
  2. Immer 给的是草稿代理,push 也能产出新对象 —— 但这个特权只在 createSlice/createReducer 里有。Immer hands you a draft, so even push produces a new object — but that only holds inside createSlice and createReducer.
  3. id 要在 prepare 里生成,reducer 必须纯,否则时间旅行失效。Generate the id in prepare; the reducer has to stay pure or time travel stops working.
  4. selector 让组件只订阅自己那部分 —— 这是 Redux 比 Context 强的具体地方。A selector lets a component subscribe to just its own part — this is the concrete place where Redux beats Context.
  5. selector 不要返回新对象,否则每次 store 变都重渲染。Do not return a new object from a selector, or the component re-renders on every store change.
  6. 这道题的规模用 useState 就够;能说出「什么时候才该上 Redux」比写完更重要。At this size useState is enough; being able to say when Redux is worth it matters more than finishing the code.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises1 个,就在这一页上面 —— 别攒着最后一起做1 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson缺口四 · Kanban 看板:一次改两个数组Gap 4 · a Kanban board: changing two arrays in one update
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 缺口二 · useRef 操作 DOM,与写一个自定义 hookGap 2 · using useRef on the DOM, and writing a custom hook