DrillLab
第 09 / 21 节LESSON 09 / 21约 12 分钟~12 min

Task 1 · Add:提交表单,新笔记进入表格Task 1 · Add: submit the form and the new note appears in the table

三道题里最简单的一道,但它建立了后两道题的全部结构。The easiest of the three tasks, but it sets up the whole structure the other two use.

2 个练习2 exercisesReact · 第 3 部分React · Part 3
这一页有什么On this page10
学完这节你会After this lesson you can
  • 独立写出 handleSubmitNote 的新增分支Write the add branch of handleSubmitNote on your own
  • 说清 note 是在哪里被构造出来的、id 从哪来Say where the note object is built and where its id comes from
  • 解释为什么这里必须造新数组Explain why a new array is required here
  • 知道对应的测试在断言什么Know what the matching test asserts
这在考试里考什么What the exam does with this

第 1 个测试直接查它。而且它确立了「子组件 onSubmit 上报 → 父组件改 notes」这条链 —— Task 3 的后半复用同一个函数。The first test checks it directly. It also sets up the chain where the child reports through onSubmit and the parent changes notes. The second half of Task 3 reuses the same function.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
react-notes-app/src/components/NoteManager/index.tsxhandleSubmitNote 的 else 分支

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

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
react-notes-app/src/components/NoteForm/index.tsxnote 在这里被构造并上报The note is built here and reported upwards
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
§01

这一问在要求什么What this task asks for

「提交表单 → 新 note 进入表格」。拆开是三件事:

  1. 用户在两个输入框里填好内容,点 Add。
  2. 产生一条完整的 Note(含一个唯一 id)。
  3. 这条 note 出现在表格里,原有的都还在

第 2 件事已经由 NoteForm 做完了(上一模块读过它)。 所以你要写的只有第 3 件事。

“Submit the form → the new note enters the table”. Split open, that is three things:

  1. The user fills in both inputs and clicks Add.
  2. A complete Note comes into being, with a unique id.
  3. That note shows up in the table, and everything already there stays.

The second one is already done by NoteForm (you read it in the previous module). So the only thing left for you is the third.

§02

这一问真正考什么What this task is really testing

表面上是「往数组里加一条」。真正考的是三点:

  • 你知不知道数据该往哪存。notes 在 NoteManager,不在 NoteForm
  • 你会不会不可变更新。push 能让数组变长,但界面不动。
  • 你能不能顺着 props 找到调用链。NoteFormonSubmit 这个 prop 是从哪来的、传的是谁。

On the surface it is “append one item to an array”. What it really tests is three things:

  • Whether you know where the data belongs. notes lives in NoteManager, not NoteForm.
  • Whether you can do an immutable update.push makes the array longer, but the screen never moves.
  • Whether you can follow props to the call chain. Where NoteForm’s onSubmit prop comes from, and what gets passed into it.
§03

先看现有代码:note 是谁造的Look at the existing code first: who builds the note

NoteForm 已经把整条 note 造好了,包括 id。NoteForm already builds the whole note, id included.

handleSubmit(在 NoteForm 里) 做了这几件事,其中第 3 步就是构造 note:

注意 id: noteToEdit ? noteToEdit.id : Date.now(): 新增时 noteToEditnull, 所以走 Date.now() —— 当前毫秒时间戳, 作为 id 足够唯一(同一毫秒内连点两次才会撞,实际上做不到)。

还有 .trim():前后空格被去掉了。 所以传到 NoteManager 的一定是干净的数据 —— 你不需要再校验一遍。

然后 onSubmit(newNote) 把它交出去。这个 onSubmit 是从 props 来的, 而 NoteManager 传给它的正是handleSubmitNote

handleSubmit (over in NoteForm) does a handful of things, and step 3 is where the note gets built:

Look at id: noteToEdit ? noteToEdit.id : Date.now(): when adding, noteToEdit is null, so it takes Date.now() — the current millisecond timestamp, unique enough for an id (you would have to click twice inside one millisecond to collide, which you cannot).

And .trim(): the surrounding spaces are gone. So what reaches NoteManager is always clean data — you do not have to validate it again.

Then onSubmit(newNote) hands it off. That onSubmit comes from props, and what NoteManager passes in is exactly handleSubmitNote:

TSXsrc/components/NoteForm/index.tsx(节选)src/components/NoteForm/index.tsx (excerpt)源项目From source
1// NoteForm 里:构造并上报
2const newNote = {
3 id: noteToEdit ? noteToEdit.id : Date.now(),
4 title: title.trim(),
5 content: content.trim(),
6};
7onSubmit(newNote);
1// Inside NoteForm: build the note and report it up
2const newNote = {
3 id: noteToEdit ? noteToEdit.id : Date.now(),
4 title: title.trim(),
5 content: content.trim(),
6};
7onSubmit(newNote);
Source: react-notes-app/src/components/NoteForm/index.tsx
TSXsrc/components/NoteManager/index.tsx(节选)src/components/NoteManager/index.tsx (excerpt)源项目From source
1// NoteManager 里:把 handleSubmitNote 接上去
2<NoteForm onSubmit={handleSubmitNote} noteToEdit={noteToEdit} />
1// Inside NoteManager: wire handleSubmitNote up
2<NoteForm onSubmit={handleSubmitNote} noteToEdit={noteToEdit} />
Source: react-notes-app/src/components/NoteManager/index.tsx
所以 NoteForm 里那句 onSubmit(newNote),实际执行的是 NoteManager 里的 handleSubmitNote(newNote)。这就是「props 传函数」这条链的全貌。So the line onSubmit(newNote) inside NoteForm really runs handleSubmitNote(newNote) inside NoteManager. That is the whole chain of passing a function through props.
§04

先想再写Think it through before you write

下面五个问题都能答上来,代码自然就出来了。Once you can answer these five questions, the code follows.

先别写代码 · 先回答这几个问题Before you write code · answer these first
1.输入是什么?—— 一条已经构造好、已经 trim 过的 Note。
2.输出是什么?—— notes 这个 state 的新值。
3.新值和旧值什么关系?—— 旧的全部保留,末尾多一条。
4.谁负责改?—— 持有 notes 的组件,也就是 NoteManager。
5.能不能直接改旧数组?—— 不能。React 靠「是不是同一个对象」判断变化。

第 3 个问题的答案「旧的全部保留,末尾多一条」, 翻译成代码就是 [...prev, submittedNote]。 展开语法把旧数组的每个元素铺开,后面接上新的。

先别写代码 · 先回答这几个问题Before you write code · answer these first
1.What is the input? A Note that is already built and already trimmed.
2.What is the output? The new value of the notes state.
3.How does the new value relate to the old one? Everything old stays, one more at the end.
4.Who is allowed to change it? The component that holds notes, i.e. NoteManager.
5.Can you edit the old array directly? No. React decides whether something changed by asking whether it is the same object.

The answer to question 3, “everything old stays, one more at the end”, turns straight into [...prev, submittedNote]. Spread lays out every element of the old array, and the new one goes after them.

§05

分步实现Build it step by step

第一步:先只考虑新增,不管编辑。(编辑是 Task 3 的事,现在假装没有。)

第二步:改成函数式更新。setNotes([...notes, submittedNote]) 也能过测试, 但项目里统一用 prev 形式。理由在上一模块讲过: 连续调用时不会拿到过期的值。

第三步:给编辑留位置。Task 3 会在这个函数里加一个分支。 所以最终形态是 if (noteToEdit) {...} else { 新增 }。 现在先只写 else 那半边。

Step one: think about adding only, ignore editing. (Editing belongs to Task 3. Pretend it does not exist yet.)

Step two: switch to the functional update.setNotes([...notes, submittedNote]) passes the tests too, but the project uses the prev form throughout. The reason came up in the previous module: back-to-back calls never read a stale value.

Step three: leave room for editing. Task 3 adds a branch inside this same function. So the final shape is if (noteToEdit) {...} else { add }. For now write only the else half.

TSX推导过程 · 第一步Working it out · step one示意Illustrative
1// 第一步:最直白的写法
2const handleSubmitNote = (submittedNote: Note) => {
3 setNotes([...notes, submittedNote]);
4};
1// Step one: the most direct version
2const handleSubmitNote = (submittedNote: Note) => {
3 setNotes([...notes, submittedNote]);
4};
TSXsrc/components/NoteManager/index.tsx源项目From source
1// 最终形态(Task 1 的那半边)
2const handleSubmitNote = (submittedNote: Note) => {
3 if (noteToEdit) {
4 // Task 3 会填这里
5 } else {
6 setNotes((prev) => [...prev, submittedNote]);
7 }
8};
1// The final shape (the half that belongs to Task 1)
2const handleSubmitNote = (submittedNote: Note) => {
3 if (noteToEdit) {
4 // Task 3 fills this in
5 } else {
6 setNotes((prev) => [...prev, submittedNote]);
7 }
8};
Source: react-notes-app/src/components/NoteManager/index.tsx
§06

为什么这样就成立了Why this works

逐段看这一行 setNotes((prev) => [...prev, submittedNote])

  • setNotes(...) —— 唯一合法的修改途径。 调用它 = 告诉 React「值变了,请重新渲染」。
  • (prev) => ... —— 函数式更新。 React 会把「此刻最新的 notes」作为 prev 传进来。
  • [...prev, submittedNote] —— 一个全新的数组。旧元素逐个铺开,新的接在末尾。 因为是新数组,React 能看出变化。

之后就是上一模块那张图的流程:state 更新 → NoteManager重新执行 → notes 多了一条 →NoteTable 收到新数组 →notes.map(...) 多产出一个 NoteItem → React 对比后往 DOM 里插一个 <tr>

Read that one line piece by piece — setNotes((prev) => [...prev, submittedNote]):

  • setNotes(...) — the only legal way to change it. Calling it means telling React “the value changed, please re-render”.
  • (prev) => ... — the functional update. React hands you the newest notes at this moment as prev.
  • [...prev, submittedNote] — a brand new array. The old items are laid out one by one and the new one is attached at the end. Because it is a new array, React can see the change.

After that it is the flow from the diagram in the previous module: state updates → NoteManager runs again → notes has one more item → NoteTable receives the new array → notes.map(...) produces one more NoteItem → React diffs and inserts one <tr> into the DOM.

§07

对应的测试The matching test

userEvent.type 模拟真人打字(一个字符一个字符触发 onChange),userEvent.click 模拟点击。 最后断言 notes-list 这个元素的文字内容里含My Title

注意这个测试的宽松之处:它只检查「文字出现了」。所以就算你把新笔记加在开头[submittedNote, ...prev]), 这个测试照样过。但题目说的是「进入表格」, 常规理解是追加到末尾 —— 而且真实答案就是追加。别因为测试宽松就随便写。

userEvent.type types like a real person (one character at a time, each firing onChange), and userEvent.click clicks. The last line asserts that the text content of the notes-list element contains My Title.

Notice how loose this test is: it only checks that the text showed up. So even if you put the new note at the front ([submittedNote, ...prev]), it still passes. But the brief says “enters the table”, which normally means appending to the end — and the real answer does append. A loose test is no excuse for a sloppy answer.

TSXsrc/NoteManager.test.tsx(第 1 个测试)src/NoteManager.test.tsx (test 1)源项目From source
1test("adds a note", async () => {
2 render(<NoteManager />);
3 await userEvent.type(screen.getByTestId("form-input"), "My Title");
4 await userEvent.type(screen.getByTestId("form-textarea"), "My Content");
5 await userEvent.click(screen.getByTestId("form-submit-button"));
6
7 expect(screen.getByTestId("notes-list")).toHaveTextContent("My Title");
8});
Source: react-notes-app/src/NoteManager.test.tsx
练习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 add logic

两个空。想清楚「旧的要不要留」和「用哪种更新形式」。

Two blanks. Decide whether the old notes have to stay, and which form of update to use.

TSXsrc/components/NoteManager/index.tsx2 个空2 blanks
1const handleSubmitNote = (submittedNote: Note) => {
2 if (noteToEdit) {
3 // Task 3
4 } else {
5 setNotes(() => [, submittedNote]);
6 }
7};
把 2 个空都填上才能检查(还差 2 个)Fill all 2 blanks to check (2 to go)
L3写整块Write a block不看答案,自己写出 Task 1Write Task 1 yourself, without looking at the answer

只给你函数签名和要求。自己写完整实现。 写完点「检查我的代码」,它会用文本规则检查你有没有踩坑。

You get the function signature and the requirements. Write the whole implementation yourself. When you are done, use the check button: it applies text rules to see whether you fell into a known trap.

要求Requirements
  • 把 submittedNote 追加到 notes 的末尾Append submittedNote to the end of notes
  • 原有的笔记全部保留Keep every note that was already there
  • 必须用函数式更新 setNotes(prev => ...)Use a functional update: setNotes(prev => ...)
  • 不许用 push / splice / unshift 修改原数组Do not change the original array with push / splice / unshift
  • 留出 if (noteToEdit) 分支的位置(Task 3 会填)Leave room for the if (noteToEdit) branch (Task 3 fills it in)
TSXsrc/components/NoteManager/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// ✗ 在 NoteForm 里自己存一份列表
2const NoteForm = ({ onSubmit }) => {
3 const [myNotes, setMyNotes] = useState<Note[]>([]);
4 const handleSubmit = (e) => {
5 setMyNotes([...myNotes, newNote]); // 存在 NoteForm 里,表格看不到
6 onSubmit(newNote);
7 };
8};
1// ✗ Keeping a second copy of the list inside NoteForm
2const NoteForm = ({ onSubmit }) => {
3 const [myNotes, setMyNotes] = useState<Note[]>([]);
4 const handleSubmit = (e) => {
5 setMyNotes([...myNotes, newNote]); // stored in NoteForm, the table never sees it
6 onSubmit(newNote);
7 };
8};
NoteTable 拿不到 NoteForm 内部的 state —— 它们是兄弟。这份 myNotes 白存了。数据只能放在共同祖先。NoteTable cannot reach state that lives insideNoteForm — they are siblings. This myNotesis stored for nothing. The data can only live in a shared parent.
TSX示意Illustrative
1// ✗ 忘了三个点
2setNotes((prev) => [prev, submittedNote]);
1// ✗ The three dots are missing
2setNotes((prev) => [prev, submittedNote]);
这会造出 [[...旧数组], 新note] —— 一个嵌套数组。 TypeScript 会报Type 'Note[]' is not assignable to type 'Note'。 好在这个错编译期就被抓到了。This builds [[...old array], new note], an array inside an array. TypeScript reportsType 'Note[]' is not assignable to type 'Note'. At least this mistake is caught at compile time.
TSX示意Illustrative
1// ✗ 又造了一个 id
2const handleSubmitNote = (submittedNote: Note) => {
3 setNotes((prev) => [...prev, { ...submittedNote, id: Date.now() }]);
4};
1// ✗ Making a second id
2const handleSubmitNote = (submittedNote: Note) => {
3 setNotes((prev) => [...prev, { ...submittedNote, id: Date.now() }]);
4};
NoteForm 已经给好 id 了,这里再造一个纯属多余。 更糟的是它会直接毁掉 Task 3 —— 编辑提交时 NoteForm 特意复用了旧 id, 而这行会把它覆盖成新 id,于是 map 找不到匹配项, 更新静默失败(列表毫无变化)。NoteForm already set the id, so building another one here is pointless. Worse, it breaks Task 3: on an edit submit NoteForm deliberately reuses the old id, and this line overwrites it with a new one. Then map finds no match and the update fails silently, with no change in the list.
迁移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.

「新增一条到列表」Add one item to a list
setX(prev => [...prev, item])setX(prev => [...prev, item])
「加在最前面」Add it at the front
setX(prev => [item, ...prev])setX(prev => [item, ...prev])
子组件已经把数据造好了The child already built the data
父组件别再加工,直接存The parent should store it as it is, not rebuild it
表单提交后要影响别处A form submit has to change something elsewhere
onSubmit 上报到共同祖先Report it up to the shared parent through onSubmit
这节的要点What to take away
  1. note 由 NoteForm 构造(含 id 和 trim),NoteManager 只负责存。NoteForm builds the note, including the id and the trim. NoteManager only stores it.
  2. onSubmit 这个 prop 接的就是 handleSubmitNote —— 顺着 props 能找到调用链。The onSubmit prop receives handleSubmitNote. Follow the props and you find the call chain.
  3. setNotes(prev => [...prev, note]) 是标准写法:新数组、旧的全留、新的在末尾。setNotes(prev => [...prev, note]) is the standard form: a new array, every old item kept, the new one at the end.
  4. 不要在 handleSubmitNote 里重新生成 id,那会毁掉 Task 3。Do not generate a new id inside handleSubmitNote. That breaks Task 3.
  5. 第 1 个测试只查文字出现,比题目要求宽松 —— 别因此偷懒。The first test only checks that the text appears, which is looser than the task requires. Do not do less because of that.

接下来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 lessonTask 2 · Delete:点 Delete,该行按 id 被移除Task 2 · Delete: click Delete and that one row is removed by id
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 先读题:三个任务、一条硬约束、四个测试Read the question first: three tasks, one rule you must not break, four tests