DrillLab
第 07 / 21 节LESSON 07 / 21约 11 分钟~11 min

派生数据与状态提升:什么不该做成 stateValues you can compute, and lifting state up: what should not be state

isFormInvalid 为什么是一行普通变量,而不是第三个 useState。Why isFormInvalid is one plain variable and not a third useState.

3 个练习3 exercisesReact · 第 2 部分React · Part 2
这一页有什么On this page5
学完这节你会After this lesson you can
  • 判断一个值该做成 state 还是当场算出来Decide whether a value should be state or should be computed on the spot
  • 说清「多余 state」会带来什么问题Explain what problems extra state causes
  • 解释为什么 notes 必须住在 NoteManager 而不是 NoteTableExplain why notes has to live in NoteManager and not in NoteTable
  • 看懂按钮文字 Add/Update 是怎么来的See where the button text Add or Update comes from
这在考试里考什么What the exam does with this

第二个测试断言「输入为空时提交按钮 disabled」。它靠的是 isFormInvalid 这个派生值。把它做成 state 是新手常见的过度设计,还容易出现「和实际输入不同步」的 bug。The second test asserts that the submit button is disabled while the input is empty. That relies on the computed value isFormInvalid. Turning it into state is a common beginner habit, and it easily produces a value that no longer matches what is in the input.

这节课要看的真实文件Real files this lesson looks at2 项 · 2 个可以展开看原文2 items · 2 can be opened
react-notes-app/src/components/NoteForm/index.tsxisFormInvalid 与按钮文字isFormInvalid and the button text
TSXindex.tsx源项目From source
1import React, { useState, useEffect } from "react";
2import type { Note } from "../../types/Note";
3
4interface NoteFormProps {
5 onSubmit: (note: Note) => void;
6 noteToEdit: Note | null;
7}
8
9const NoteForm: React.FC<NoteFormProps> = ({ onSubmit, noteToEdit }) => {
10 const [title, setTitle] = useState("");
11 const [content, setContent] = useState("");
12
13 useEffect(() => {
14 if (noteToEdit) {
15 setTitle(noteToEdit.title);
16 setContent(noteToEdit.content);
17 } else {
18 setTitle("");
19 setContent("");
20 }
21 }, [noteToEdit]);
22
23 const isFormInvalid = title.trim() === "" || content.trim() === "";
24
25 const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
26 event.preventDefault();
27 if (isFormInvalid) return;
28 const newNote = {
29 id: noteToEdit ? noteToEdit.id : Date.now(),
30 title: title.trim(),
31 content: content.trim(),
32 };
33 onSubmit(newNote);
34 setTitle("");
35 setContent("");
36 };
37
38 return (
39 <div className="card w-200 pt-30 pb-8 mt-15 mb-15">
40 <form onSubmit={handleSubmit} data-testid="note-form">
41 <section className="layout-row align-items-center justify-content-center mt-20 mr-20 ml-20">
42 <label className="form-title-label">Title:</label>
43 <input
44 type="text"
45 placeholder="Title"
46 value={title}
47 onChange={(e) => setTitle(e.target.value)}
48 data-testid="form-input"
49 className="form-input"
50 />
51 </section>
52
53 <section className="layout-row align-items-center justify-content-center mt-20 mr-20 ml-20">
54 <label className="form-content-label">Content:</label>
55 <textarea
56 placeholder="Content"
57 value={content}
58 onChange={(e) => setContent(e.target.value)}
59 data-testid="form-textarea"
60 className="form-textarea"
61 />
62 </section>
63
64 <section className="layout-row align-items-center justify-content-center mt-20 mr-20 ml-20">
65 <button
66 type="submit"
67 disabled={isFormInvalid}
68 data-testid="form-submit-button"
69 >
70 {noteToEdit ? "Update" : "Add"}
71 </button>
72 </section>
73 </form>
74 </div>
75 );
76};
77
78export default NoteForm;
Source: react-notes-app/src/components/NoteForm/index.tsx
react-notes-app/src/components/NoteManager/index.tsx状态提升的落点Where the lifted state ends up
TSXindex.tsx源项目From source
1import { useState } from "react";
2import type { Note } from "../../types/Note";
3import NoteForm from "../NoteForm";
4import NoteTable from "../NoteTable";
5
6const NoteManager: React.FC = () => {
7 const [notes, setNotes] = useState<Note[]>([]);
8 const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
9
10 const handleSubmitNote = (submittedNote: Note) => {
11 if (noteToEdit) {
12 setNotes((prev) =>
13 prev.map((note) =>
14 note.id === submittedNote.id ? submittedNote : note,
15 ),
16 );
17 setNoteToEdit(null);
18 } else {
19 setNotes((prev) => [...prev, submittedNote]);
20 }
21 };
22
23 const handleDelete = (id: number) => {
24 setNotes((prev) => prev.filter((note) => note.id !== id));
25 };
26
27 const handleEdit = (note: Note) => {
28 setNoteToEdit(note);
29 };
30
31 return (
32 <div
33 className="layout-column align-items-center justify-content-start"
34 data-testid="note-manager"
35 >
36 <NoteForm onSubmit={handleSubmitNote} noteToEdit={noteToEdit} />
37 <NoteTable notes={notes} onDelete={handleDelete} onEdit={handleEdit} />
38 </div>
39 );
40};
41
42export default NoteManager;
Source: react-notes-app/src/components/NoteManager/index.tsx
§01

能算出来的,就不要存If you can compute it, do not store it

state 越少,能出错的地方越少。The less state you have, the fewer places there are for things to go wrong.

「表单是否无效」完全由 titlecontent决定。所以它不需要单独存 —— 每次渲染时算一遍就行:

这种「从已有 state 直接算出来的值」叫派生数据(derived state)。 它自动就是最新的,因为每次渲染都重算。

如果做成 useState 会怎样? 你得在每一处修改 title 或 content 的地方记得同步更新它。 漏掉一处,就出现「输入框有内容,按钮还是灰的」这种 bug。 这类 bug 的名字叫状态不一致, 根源就是「同一个事实存了两份」。

判断方法很简单:「这个值能不能只用现有的 state 和 props 算出来?」 能 → 别做 state。

“Is the form invalid” follows entirely from title and content. So it needs no storage of its own — work it out once per render:

A value computed straight out of state you already have is called derived state. It is always current, because every render recomputes it.

What happens if you make it a useState? Then every single place that changes title or content has to remember to update it too. Miss one and you get “the input has text but the button is still grey”. That family of bug is called inconsistent state, and the cause is always the same fact stored twice.

The test is simple:“can this value be worked out from the state and props I already have?” Yes → do not make it state.

TSXsrc/components/NoteForm/index.tsx(节选)src/components/NoteForm/index.tsx (excerpt)源项目From source
1const isFormInvalid = title.trim() === "" || content.trim() === "";
2
3// 用在两处:
4<button type="submit" disabled={isFormInvalid} data-testid="form-submit-button">
5if (isFormInvalid) return; // handleSubmit 里再挡一次
1const isFormInvalid = title.trim() === "" || content.trim() === "";
2
3// used in two places:
4<button type="submit" disabled={isFormInvalid} data-testid="form-submit-button">
5if (isFormInvalid) return; // handleSubmit blocks it a second time
Source: react-notes-app/src/components/NoteForm/index.tsx
注意 .trim() —— 只输入空格也算无效。这个细节题目没写,但它是合理实现的一部分,而且测试里 disabled 那条也能覆盖到初始空值的情况。Note the .trim() — spaces only still counts as invalid. The task never says this, but it is part of a sensible implementation, and the disabled assertion in the tests covers the empty initial values too.
§02

按钮文字:同一个 prop 决定三件事The button text: one prop decides three things

noteToEdit 这一个 prop,同时决定了:

  1. 输入框里显示什么 —— 通过上一节那个 useEffect。
  2. 按钮上写 Add 还是 Update ——{noteToEdit ? "Update" : "Add"}, 渲染时当场算。
  3. 提交时是新建还是更新 ——id: noteToEdit ? noteToEdit.id : Date.now()

第 4 个测试会显式断言按钮文字:expect(screen.getByTestId("form-submit-button")).toHaveTextContent("Update")大小写和拼写必须一模一样 —— 写成 "update""Save" 都会挂。

This single noteToEdit prop decides three things at once:

  1. What the inputs show — through the useEffect from the last lesson.
  2. Whether the button says Add or Update{noteToEdit ? "Update" : "Add"}, worked out during render.
  3. Whether submitting creates or updatesid: noteToEdit ? noteToEdit.id : Date.now().

The fourth test asserts the button text outright: expect(screen.getByTestId("form-submit-button")).toHaveTextContent("Update"). Spelling and case have to match exactly"update" or "Save" both fail.

TSXsrc/components/NoteForm/index.tsx(节选)src/components/NoteForm/index.tsx (excerpt)源项目From source
1<button
2 type="submit"
3 disabled={isFormInvalid}
4 data-testid="form-submit-button"
5>
6 {noteToEdit ? "Update" : "Add"}
7</button>
Source: react-notes-app/src/components/NoteForm/index.tsx
§03

状态提升:数据放在「需要它的组件的最近共同祖先」Lifting state up: put the data in the closest shared parent of the components that need it

notes 为什么必须住在 NoteManager? 因为有两方需要它:

  • NoteTable它来渲染。
  • NoteForm 的提交要它 (通过 onSubmit 间接改)。

这两个组件是兄弟,而 React 的数据只能往下流。 所以数据必须放在它们的最近共同祖先 ——NoteManager。这个动作叫状态提升(lifting state up)

反过来看 title / content: 只有 NoteForm 用得到,所以留在NoteForm 自己身上就好。不要无脑把所有 state 都提到顶层 —— 那会让顶层组件变成一个什么都管的怪物。

noteToEdit 是个有意思的中间情况: 它由 NoteTable 的点击产生(NoteItem → onEdit),被 NoteForm 消费。 所以它也必须住在 NoteManager

Why does notes have to live in NoteManager? Because two parties need it:

  • NoteTable has to read it to render.
  • NoteForm’s submit has to change it (indirectly, through onSubmit).

Those two are siblings, and React data only flows downward. So the data has to sit at their closest common ancestorNoteManager. The move has a name: lifting state up.

Now look at title / content the other way round: only NoteForm ever needs them, so they stay inside NoteForm. Do not hoist every piece of state to the top out of habit — that turns the top component into a monster that manages everything.

noteToEdit is an interesting middle case: it is produced by a click in NoteTable (NoteItem → onEdit) and consumed by NoteForm. So it has to live in NoteManager too.

Text四个值的归属Where the four values belong已跑通Verified
1谁需要它 → 它应该住哪
2─────────────────────────────────────────────────
3notes NoteTable 读 + NoteForm 间接改 NoteManager(共同祖先)
4noteToEdit NoteItem 产生 + NoteForm 消费 NoteManager(共同祖先)
5title 只有 NoteForm 用 NoteForm(自己留着)
6content 只有 NoteForm 用 NoteForm(自己留着)
7isFormInvalid 由 title/content 算出 不是 state,当场算
1who needs it → where it should live
2─────────────────────────────────────────────────
3notes NoteTable reads + NoteForm changes NoteManager (shared ancestor)
4noteToEdit NoteItem creates + NoteForm uses NoteManager (shared ancestor)
5title only NoteForm uses it NoteForm (keeps it local)
6content only NoteForm uses it NoteForm (keeps it local)
7isFormInvalid computed from title/content not state, computed inline
练习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.

L1认出来Spot it哪个应该做成 stateWhich one should become state

假设要给 Notes Manager 加一个「显示当前有几条笔记」的文字。 这个数字应该怎么实现?

Say you have to add a line to Notes Manager that shows how many notes there are right now. How should that number be produced?

先选一个选项Pick an option first
L1认出来Spot it这个 state 该住哪Where should this state live

假设要加一个搜索框(在 NoteForm 上方,属于 NoteManager 的直接子元素), 输入关键词后表格只显示匹配的笔记。 搜索关键词这个 state 该放在哪?

Say you add a search box above NoteForm, as a direct child of NoteManager. Once a keyword is typed, the table shows only the notes that match. Where should the state holding that keyword go?

先选一个选项Pick an option first
L3写整块Write a block写出派生数据与按钮文字Write the computed value and the button text

补出 isFormInvalid 和按钮的两处动态部分。 注意:只输入空格也应该算无效。

Fill in isFormInvalid and the two changing parts of the button. Note: spaces only should also count as invalid.

要求Requirements
  • isFormInvalid 是一个普通 const,不许用 useStateisFormInvalid is a plain const; useState is not allowed
  • 只输入空格也要判定为无效(用 trim)Spaces only must also count as invalid (use trim)
  • 按钮在表单无效时 disabledThe button is disabled while the form is invalid
  • 按钮文字:noteToEdit 存在时是 Update,否则是 Add(大小写必须一致)Button text: Update when noteToEdit exists, otherwise Add (the capitals must match)
  • 不许改动 data-testidDo not change any data-testid
TSXsrc/components/NoteForm/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.

迁移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.

「显示筛选/排序后的列表」Show a filtered or sorted list
一个 state 存条件 + 一个派生数组,别存结果One state for the condition plus a computed array. Do not store the result
「显示总数 / 是否为空 / 是否可提交」Show a count, or whether it is empty, or whether it can be submitted
派生数据,当场算Computed data. Work it out at render time
两个兄弟组件都要用同一份数据Two sibling components need the same data
提升到最近共同祖先Move it up to their closest shared parent
「同一个事实存了两份」The same fact is stored in two places
删掉一份,改成派生Delete one copy and compute it instead
这节的要点What to take away
  1. 能从现有 state / props 算出来的值,不要做成 state。If a value can be computed from existing state or props, do not make it state.
  2. 多余 state 的代价是「状态不一致」,而且是最难查的一类 bug。Extra state costs you consistency, and that is one of the hardest kinds of bug to find.
  3. isFormInvalid 是派生数据,每次渲染重算,永远和输入一致。isFormInvalid is computed. It is worked out again on every render, so it always matches the input.
  4. state 放在「需要它的组件的最近共同祖先」,不要一律提到顶层。Put state in the closest shared parent of the components that need it. Do not push everything to the top.
  5. 按钮文字 Add / Update 大小写必须一致 —— 测试会直接断言字符串。The button text Add and Update must match exactly, capital letters included, because the tests assert on the string.

接下来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先读题:三个任务、一条硬约束、四个测试Read the question first: three tasks, one rule you must not break, four tests
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: useEffect:把 props 的变化同步进 stateuseEffect: copying a change in props into state