DrillLab
第 04 / 21 节LESSON 04 / 21约 13 分钟~13 min

受控输入:value + onChange 的闭环Controlled inputs: the loop between value and onChange

输入框里的字,其实存在 React 的 state 里,不在 DOM 里。The text you type sits in React state, not in the DOM.

2 个练习2 exercisesReact · 第 2 部分React · Part 2
这一页有什么On this page7
学完这节你会After this lesson you can
  • 说清「受控」到底控的是什么Explain what a controlled input actually controls
  • 写出 value + onChange 的完整闭环Write the full value + onChange loop
  • 知道只写 value 不写 onChange 会怎样Know what happens if you write value but no onChange
  • 看懂表单提交里 event.preventDefault() 的必要性See why event.preventDefault() is needed when a form is submitted
这在考试里考什么What the exam does with this

判卷测试用 userEvent.type() 往输入框里打字,然后断言表格内容。如果输入框不是受控的,打进去的字拿不到,Task 1 直接挂。The grading tests type into the input with userEvent.type() and then assert on the table contents. If the input is not controlled, the typed text never reaches your code and Task 1 fails.

这节课要看的真实文件Real files this lesson looks at1 项 · 1 个可以展开看原文1 items · 1 can be opened
react-notes-app/src/components/NoteForm/index.tsx两个受控输入 + 表单提交的完整实现The complete implementation of the two controlled inputs and the form submit
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

「受控」的意思是:唯一真相在 state 里Controlled means the only source of truth is the state

输入框自己不做主,它只显示 state 告诉它的东西。The input decides nothing on its own. It shows whatever the state tells it to show.

原生 HTML 里,<input> 自己记着用户输入了什么。 你要读它得 document.querySelector(...).value。 这叫非受控

React 里的常规做法是反过来:

  1. value={title} —— 输入框显示什么, 由 state 决定。
  2. onChange={(e) => setTitle(e.target.value)} —— 用户敲键盘时,把新值写进 state。
  3. state 变了 → 重新渲染 → 输入框显示新值。

看起来绕了一圈,但换来一个巨大好处:任何时候,title 这个变量就是输入框里的内容。你不需要去 DOM 里读,也不会出现「显示的和读到的不一致」。

这个闭环叫 受控组件(controlled component)

In plain HTML an <input> remembers what the user typed. To read it you go through document.querySelector(...).value. That is uncontrolled.

React normally turns it around:

  1. value={title} — what the input shows is decided by state.
  2. onChange={(e) => setTitle(e.target.value)} — as the user types, the new value goes into state.
  3. state changed → re-render → the input shows the new value.

It looks like a detour, and it buys one big thing: at any moment, the title variable is exactly what is inside the input. You never read the DOM, so “what is shown” can never drift from “what you read”.

This loop is called a controlled component.

TSXsrc/components/NoteForm/index.tsx(节选)src/components/NoteForm/index.tsx (excerpt)源项目From source
1const [title, setTitle] = useState("");
2
3<input
4 type="text"
5 placeholder="Title"
6 value={title} // ① 显示什么由 state 说
7 onChange={(e) => setTitle(e.target.value)} // ② 敲键盘就写回 state
8 data-testid="form-input"
9 className="form-input"
10/>
1const [title, setTitle] = useState("");
2
3<input
4 type="text"
5 placeholder="Title"
6 value={title} // ① state decides what is shown
7 onChange={(e) => setTitle(e.target.value)} // ② typing writes back to state
8 data-testid="form-input"
9 className="form-input"
10/>
Source: react-notes-app/src/components/NoteForm/index.tsx
e.target 就是那个 input 元素,e.target.value 是它此刻的内容。textarea 的写法完全一样 —— 项目里 content 就是这么做的。e.target is that input element, and e.target.value is its content right now. A textarea is written the same way — that is how content is done in the project.
§02

只写 value 不写 onChange 会怎样What happens if you write value but no onChange

输入框会变成只读的。这是个很容易踩的坑。The input turns read-only. This mistake is very easy to make.

想清楚这个逻辑链:value={title} 意味着 「输入框显示的永远是 title」。 而 title 只能通过 setTitle 改。 如果没有 onChange,就没人调 setTitletitle 永远是初始值 ""

结果:你在输入框里敲什么都不显示。React 开发模式下会给一条警告:

这条警告也提示了另一条路:如果你只是想给一个初始值、 之后不管它,用 defaultValue(非受控)。 但这道题必须用受控 —— 因为提交时要读到内容, 而且 Task 3 要能从外部把值填进去

Follow the chain. value={title} means “this input always shows title”. And title can only change through setTitle. With no onChange, nobody ever calls setTitle, so title stays at its initial "".

Result: nothing you type shows up. React gives you a warning in development mode:

That warning also points at the other road: if you only want to set an initial value and never touch it again, use defaultValue (uncontrolled). This paper needs controlled inputs though — submitting has to read the content, and Task 3 has to push a value in from outside.

TerminalReact 的警告The warning from React示意Illustrative
1Warning: You provided a `value` prop to a form field without an
2`onChange` handler. This will render a read-only field. If the field should
3be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
§03

表单提交:preventDefault 不是可选项Submitting a form: preventDefault is not optional

这个项目用的是真正的 <form> +<button type="submit">, 提交处理挂在 formonSubmit 上。

浏览器的默认行为是:提交表单 = 发一个 HTTP 请求并刷新页面。在单页应用里这是灾难 —— 页面一刷新,所有 state 归零, 刚添加的笔记全没了。

event.preventDefault() 就是在说 「别做默认那件事,我自己处理」。忘了它, Task 1 的表现是「点 Add 之后页面闪一下,什么都没发生」。

看真实实现里 handleSubmit 的五个动作,顺序很讲究:

  1. event.preventDefault() —— 先拦住浏览器。
  2. if (isFormInvalid) return —— 空内容就不提交。
  3. 构造 note。注意 id:noteToEdit ? noteToEdit.id : Date.now() ——这一行同时服务 Add 和 Update 两种情况, 是 Task 3 的关键之一。
  4. onSubmit(newNote) —— 上报给父组件。
  5. setTitle("") / setContent("") —— 清空表单。

This project uses a real <form> plus<button type="submit">, with the submit handler sitting on the form’s onSubmit.

The browser’s default behaviour is: submitting a form means firing an HTTP request and reloading the page. In a single-page app that is a disaster — one reload and every piece of state is back to zero, including the note you just added.

event.preventDefault() is how you say “skip the default, I will handle this myself”. Forget it and Task 1 looks like this: you press Add, the page blinks, nothing happens.

Here are the five things the real handleSubmit does, and the order is deliberate:

  1. event.preventDefault() — hold the browser back first.
  2. if (isFormInvalid) return — do not submit empty content.
  3. Build the note. Watch the id: noteToEdit ? noteToEdit.id : Date.now()that one line serves both Add and Update, and it is one of the keys to Task 3.
  4. onSubmit(newNote) — report it up to the parent.
  5. setTitle("") / setContent("") — clear the form.
TSXsrc/components/NoteForm/index.tsx(节选)src/components/NoteForm/index.tsx (excerpt)源项目From source
1const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
2 event.preventDefault();
3 if (isFormInvalid) return;
4 const newNote = {
5 id: noteToEdit ? noteToEdit.id : Date.now(),
6 title: title.trim(),
7 content: content.trim(),
8 };
9 onSubmit(newNote);
10 setTitle("");
11 setContent("");
12};
Source: react-notes-app/src/components/NoteForm/index.tsx
§04

把整个 NoteForm 读一遍Read the whole of NoteForm once

这是这道题最密集的一个文件。上面讲的四件事都在里面。This is the densest file in the task. All four points above appear in it.

读的时候留意四处:两个 state(第 10–11 行)、useEffect 同步(13–21 行,下一节细讲)、派生数据 isFormInvalid(23 行)、按钮文字随 noteToEdit 变(70 行)。

Four spots to watch as you read: the two pieces of state (lines 10–11), the useEffect that syncs (lines 13–21, covered in the next lesson), the derived isFormInvalid (line 23), and the button text following noteToEdit (line 70).

TSXsrc/components/NoteForm/index.tsx(全文)src/components/NoteForm/index.tsx (whole file)源项目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
练习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补全受控输入的闭环Complete the loop of a controlled input

NoteForm 里 textarea 那一段补全。 三个空构成一个完整的闭环。

Fill in the textarea part of NoteForm. The three blanks together form one complete loop.

TSXsrc/components/NoteForm/index.tsx3 个空3 blanks
1const [content, setContent] = ("");
2
3<textarea
4 placeholder="Content"
5 value={}
6 onChange={(e) => setContent()}
7 data-testid="form-textarea"
8 className="form-textarea"
9/>
把 3 个空都填上才能检查(还差 3 个)Fill all 3 blanks to check (3 to go)
L2Debug LabDebug LabDebug Lab · 点 Add 之后页面闪一下,笔记没了Debug Lab · the page blinks after Add and the note is gone

填好标题和内容,点 Add。页面明显闪了一下, 地址栏出现了 ?,表格还是空的。

Fill in a title and some content, then press Add. The page clearly blinks, a ? appears in the address bar, and the table is still empty.

第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
# 没有 JavaScript 报错。 # 现象:点击 Add 后 # - 页面整体刷新了一次 # - 地址栏从 http://localhost:5173/ 变成 http://localhost:5173/? # - 输入框被清空,表格依然是空的 # - React DevTools 里所有 state 都回到了初始值# No JavaScript error. # Symptom: after clicking Add # - the whole page reloaded once # - the address bar changed from http://localhost:5173/ to http://localhost:5173/? # - the inputs were cleared and the table is still empty # - in React DevTools every piece of state is back to its initial value
TSX有问题的 handleSubmitThe broken handleSubmit示意Illustrative
1const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
2 if (isFormInvalid) return;
3 const newNote = { id: Date.now(), title: title.trim(), content: content.trim() };
4 onSubmit(newNote);
5 setTitle("");
6 setContent("");
7};
第 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// ✗ onChange 里忘了 .value
2<input value={title} onChange={(e) => setTitle(e.target)} />
1// ✗ .value is missing inside onChange
2<input value={title} onChange={(e) => setTitle(e.target)} />
e.target 是那个 DOM 元素对象,不是字符串。 TypeScript 会报Argument of type 'EventTarget' is not assignable to parameter of type 'string'。 这是好事 —— 在 JavaScript 项目里,这个错会一直藏到运行时。e.target is the DOM element object, not a string. TypeScript reportsArgument of type 'EventTarget' is not assignable to parameter of type 'string'. That is a good thing. In a plain JavaScript project this mistake stays hidden until the code runs.
TSX示意Illustrative
1// ✗ 提交后忘了清空表单
2onSubmit(newNote);
3// 少了 setTitle("") 和 setContent("")
1// ✗ the form is not cleared after submitting
2onSubmit(newNote);
3// setTitle("") and setContent("") are missing
测试里第四个用例(编辑)会先 cleartype,所以不清空不一定让测试挂。 但用户体验上,添加完一条之后输入框还留着上一条的内容, 明显是 bug。题目没写的细节,也是评分点。The fourth test (editing) calls clear before it callstype, so leaving the form filled doesnot always fail the tests. But for the user, an input that still holds the previous note after it was added is clearly a bug.Details the task did not spell out are graded too.
迁移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.

「输入内容变化时同步更新页面」The page has to update as the user types
受控输入:value + onChange + stateControlled input: value + onChange + state
「点了提交按钮页面就刷新」The page reloads when the submit button is clicked
event.preventDefault()event.preventDefault()
输入框敲不进字You cannot type anything into the input
有 value 但漏了 onChangevalue is set but onChange is missing
「表单要能被外部填充」Something outside the form has to fill the form in
必须受控,非受控做不到It has to be controlled; an uncontrolled input cannot do this
这节的要点What to take away
  1. 受控输入 = 显示由 state 决定(value),输入写回 state(onChange),形成闭环。A controlled input shows what the state says (value) and writes typing back into the state (onChange). That closes the loop.
  2. e.target.value 才是内容;e.target 是元素,e 是事件。e.target.value is the text. e.target is the element, and e is the event.
  3. 只写 value 不写 onChange,输入框会变成只读。value without onChange makes the input read-only.
  4. form 提交必须 event.preventDefault(),否则页面刷新、state 归零。A form submit needs event.preventDefault(), or the page reloads and all state is reset.
  5. id: noteToEdit ? noteToEdit.id : Date.now() 一行同时服务新增和更新。The single line id: noteToEdit ? noteToEdit.id : Date.now() serves adding and updating at the same time.

接下来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列表渲染与 keyRendering a list, and the key prop
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: useState:让界面跟着数据变useState: making the screen follow the data