写了三遍 Todo List,我终于搞懂了 React 父子组件到底怎么通信

作者:To_OC日期:2026/7/28

上来就踩了个最经典的坑

我上周写这个 Todo List 的时候,第一版写得特别快,二十分钟就把界面和逻辑堆完了。然后点复选框试了一下 —— 纹丝不动。

控制台没报错,代码看着也没写错,我对着 checked={todo.completed} 这行盯了十分钟,来回改了好几种写法,勾选状态就是不更新。当时我人都懵了,心想难道我学的 React 是假的?

后来随手打印了一下 todo 对象,发现值其实已经变了,但界面就是不刷新。那一刻我突然反应过来:我直接在子组件里改了 props 传过来的对象属性。

就是这么个入门级的坑,结结实实卡了我半小时。也正是因为这个坑,我才停下来好好捋了一遍 React 父子组件通信的逻辑,而不是照着教程抄完就完事。

先别急着写代码,先想清楚组件怎么拆

最开始我所有代码全塞在 App.js 里,输入框、列表、统计栏挤成一团。加个 “清除已完成” 按钮都要翻半天找位置,越写越烦躁。

后来干脆停手不写了,先在草稿纸上画组件结构。其实拆分逻辑特别朴素:功能独立的块,就单独拎出来做一个组件

  • 顶部输入框,只负责接收用户输入、添加新 todo,单独拆成 TodoInput
  • 中间的列表,只负责渲染每一条 todo、处理勾选和删除,拆成 TodoList
  • 底部的统计数字和清除按钮,只负责展示数据、触发清空操作,拆成 TodoStats

拆完之后整个项目目录一下就清爽了:

1src/
2├── App.js       // 父组件,管所有数据
3├── App.css
4└── components/
5    ├── TodoInput.js
6    ├── TodoList.js
7    └── TodoStats.js
8

说实话以前我总觉得组件拆分是面试用的套话,真自己写一遍才明白好处:以后想改输入框的样式,直接去 TodoInput 里找;想调整列表的排版,就去 TodoList 改。不用在几百行代码里大海捞针。

父子通信到底是个啥逻辑

拆完组件问题就来了:数据放哪?总不能每个组件自己存一份 todo 数据吧,那数据不同步肯定乱套。

答案很简单:数据全部放在最顶层的父组件 App 里统一管理,子组件自己不存数据,只负责渲染和触发事件

我当时想了个特别土的比方,一下就通了: 父组件 App 就是公司老板,手里攥着唯一的一份任务清单(也就是 todos 状态)。下面三个子组件是三个员工:

  • TodoInput 是前台,负责接新任务,但不能自己往清单上写,得报告老板,由老板加进去
  • TodoList 是执行部,负责展示任务,员工完成了也不能自己打勾,得告诉老板,老板来改状态
  • TodoStats 是行政,只负责数数字,想清掉已完成的任务,也得请示老板

所有修改都必须经过老板,老板改完清单,再把最新版发给所有员工同步。这样就永远不会出现 “你改了我不知道,我改了你不知道” 的情况,数据永远是统一的。

换成 React 的术语就是:数据通过 props 向下传递,事件通过回调函数向上触发。子组件没有权力修改父组件的数据,只能调用父组件传下来的方法,通知父组件 “该改数据了”。

一步步把代码写出来

道理讲通了,写代码其实就顺了。我把完整的可运行代码拆开来,说一下每部分我当时是怎么想的。

父组件:所有数据都攥在手里

父组件的核心就两件事:定义 todos 状态,以及写一堆修改状态的方法。

1import { useState } from "react";
2import TodoInput from "./components/TodoInput"
3import TodoList from "./components/TodoList"
4import TodoStats from "./components/TodoStats"
5import "./App.css"
6
7const App = () => {
8  // 所有 todo 数据都存在这里,唯一数据源
9  const [todos, setTodos] = useState([
10    { id: 1, text: "学习 React", completed: false },
11    { id: 2, text: "学习 React Native", completed: false },
12    { id: 3, text: "学习 React Hooks", completed: true },
13  ])
14
15  // 添加 todo
16  const addTodo = (text) => {
17    if(text.trim() === "") return
18    
19    // 注意这里:必须返回全新的数组,别直接 push 原数组
20    // 别问我为什么知道,点添加按钮界面纹丝不动的时候你就懂了
21    setTodos([
22      { id: +Date.now(), text: text, completed: false },
23      ...todos
24    ])
25  }
26
27  // 切换 todo 的完成状态
28  const toggleTodo = (id) => {
29    // 不仅数组要新,匹配到的那一项也要返回新对象
30    // 直接 todo.completed = !todo.completed 是没用的
31    setTodos(todos.map(todo => 
32      todo.id === id ? {...todo, completed: !todo.completed} : todo
33    ))
34  }
35
36  // 删除单条 todo
37  const deleteTodo = (id) => {
38    setTodos(todos.filter(todo => todo.id !== id))
39  }
40  
41  // 清除所有已完成的 todo
42  const clearCompleted = () => {
43    setTodos(todos.filter(todo => !todo.completed))
44  }
45
46  // 统计数据直接根据状态算就行,不用单独存
47  const activeCount = todos.filter(todo => !todo.completed).length;
48  const completedCount = todos.length - activeCount;
49
50  return (
51    <div>
52      <h1>My Todo List</h1>
53      {/* 把添加方法传给输入组件 */}
54      <TodoInput onAdd={addTodo}/>
55      {/* 把数据和两个操作方法传给列表组件 */}
56      <TodoList 
57        todos={todos} 
58        onToggle={toggleTodo} 
59        onDelete={deleteTodo}
60      />
61      {/* 把统计数字和清除方法传给统计组件 */}
62      <TodoStats
63        total={todos.length}
64        active={activeCount}
65        completed={completedCount}
66        onClearCompleted={clearCompleted}
67      />
68    </div>
69  )
70}
71
72export default App
73

这里最关键的一点:每次修改状态,都要返回一个全新的数组 / 对象。 React 是通过对比引用地址来判断数据有没有变的。你直接改原数组里的属性,数组本身的地址没变,React 就认为数据没改,自然不会刷新界面。所以 map、filter、展开运算符该用就用,别偷懒。

输入组件:自己的小状态自己管

TodoInput 是我觉得最有意思的一个组件,它既有自己的内部状态,又要和父组件通信。

1import { useState } from "react";
2
3const TodoInput = ({ onAdd }) => {
4    // 输入框的临时值是组件自己的事,不用上报给父组件
5    const [inputValue, setInputValue] = useState("");
6
7    const handleSubmit = (e) => {
8        e.preventDefault();
9        // 只有用户点提交的时候,才通知父组件加数据
10        onAdd(inputValue);
11        // 加完清空输入框,自己的状态自己管
12        setInputValue("");
13    }
14
15    return (
16        <form className="todo-input" onSubmit={handleSubmit}>
17            <input 
18                type="text" 
19                value={inputValue} 
20                onChange={(e) => setInputValue(e.target.value)}
21                placeholder="添加新的 todo"
22                autoFocus
23            />
24            <button type="submit">添加</button>
25        </form>
26    )
27}
28
29export default TodoInput
30

不是所有状态都要往父组件塞的。像输入框里的临时文字,用户打字的过程中根本不需要影响别的组件,就放在子组件自己这里就行。只有用户按下回车、点了添加按钮,真正要新增数据的时候,再调用父组件传过来的 onAdd 方法把内容传上去。

这就是局部状态全局状态的区别。该放哪就放哪,别什么都堆到顶层,不然父组件会越来越臃肿。

列表组件:我只负责渲染,改数据别找我

TodoList 就是个典型的 “纯渲染组件”,它拿到什么数据就渲染什么界面,自己不存任何数据。

1const TodoList = ({ todos, onToggle, onDelete }) => {
2    return (
3        <ul className="todo-list">
4            {
5                todos.length === 0 ? (
6                    <li className="empty">暂无 todo</li>
7                ) : (
8                    todos.map(todo => 
9                        <li key={todo.id} className={todo.completed ? "completed" : ""}>
10                            <label>
11                                <input 
12                                    type="checkbox"
13                                    checked={todo.completed}
14                                    // 勾选的时候,把 id 传给父组件的方法
15                                    onChange={() => onToggle(todo.id)} 
16                                />
17                                <span>{todo.text}</span>
18                            </label>
19                            {/* 这里一定要包箭头函数!别写成 onClick={onDelete(todo.id)} */}
20                            {/* 我第一次写的时候页面一刷新,所有 todo 全没了,人都傻了 */}
21                            <button onClick={() => onDelete(todo.id)}>删除</button>
22                        </li>
23                    )
24                )
25            }
26        </ul>
27    )
28}
29
30export default TodoList
31

提个很容易踩的坑 key 别图省事用数组下标 index。如果后面有删除、排序的操作,用 index 当 key 会出现渲染错乱、状态错位的问题。每条数据有唯一 id 就一定要用 id。

另外就是事件回调传参的问题。如果你直接写 onClick={onDelete(todo.id)},页面渲染的时候这行代码就会立刻执行,根本等不到你点击。所以必须用箭头函数包一层,或者用 bind 绑定参数。

统计组件:最纯粹的 “工具人”

TodoStats 就更简单了,连自己的状态都没有,完全靠父组件传进来的 props 渲染。

1const TodoStats = ({ total, active, completed, onClearCompleted }) => {
2    return (
3        <div className="todo-stats">
4            <p>Total: {total} | Active: {active} | Completed: {completed}</p>
5            {
6                // 只有已完成数量大于 0 才显示按钮
7                completed > 0 && (
8                    <button 
9                        className="clear-btn" 
10                        onClick={onClearCompleted}
11                    >
12                        清除已完成
13                    </button>
14                )
15            }
16        </div>
17    )
18}
19
20export default TodoStats
21

这种组件也叫 “无状态组件” 或者 “展示组件”,它的全部职责就是把传进来的数据显示出来,再把点击事件交还给父组件。逻辑越纯粹,以后越不容易出 bug。

全部写完跑起来就是这个效果:

回头再想:为什么非要绕这一圈?

说实话最开始我觉得这设计也太反人类了,改个数据还要绕一大圈,子组件直接改不就完了?

直到我后来加功能,有一次数据对不上出了 bug。我不用挨个去三个子组件里翻代码,直接打开 App.js,盯着那四个修改状态的函数看,两分钟就定位到问题了。

那一刻我才明白单向数据流的好处。

所有数据修改的入口都收口在父组件里,数据从哪里来、被谁改了、怎么改的,一目了然。如果每个子组件都能随便改数据,项目一大、组件一多,出了问题你根本不知道是哪个组件偷偷改了状态,查 bug 查到怀疑人生。

这就是 React 推崇的单一数据源思想:同一份数据,只在一个地方存储和修改,所有组件都从这里拿数据。规矩定死了,代码就不会乱。

我踩过的三个坑,你们别再踩了

1. 直接修改 state 里的对象 / 数组,界面不更新

这应该是每个 React 初学者都会踩的坑。直接 todo.completed = true 或者 todos.push(xxx),数据变了但界面不刷新。

原因就是 React 靠引用变化判断是否更新。你改了对象里的属性,但对象本身的地址没变,React 就认为数据没变化,不会触发重渲染。

正确姿势:数组用 map、filter、展开运算符返回新数组;对象用展开运算符返回新对象。

2. 事件回调加了括号,页面一加载就执行

onClick={handleDelete(id)} 的时候,React 渲染 JSX 就会直接执行这个函数,根本不会等点击事件触发。

正确姿势:写成 onClick={() => handleDelete(id)},用箭头函数包裹一层,或者用 bind 绑定参数。

3. 刚调用完 setState 就读数据,拿到的是旧值

我当时加完 todo 想立刻打印一下最新的列表,结果每次都少一条。以为是添加逻辑写错了,查了半天才知道 useState 的更新是异步批量的。

调用 setTodos 之后,当前函数作用域里的 todos 还是旧值,要等下一次渲染才会变成新的。如果需要用到最新的数据,直接用你准备 set 进去的那份新数据就行,别去读状态变量。

最后说两句

折腾完这三遍 Todo List,我心里才算真的踏实了,不是那种照着教程抄完的似懂非懂。

第一个感触是,组件拆分真不是面试题里的标准答案,是实实在在能提升开发体验的东西。每个组件只干一件事,以后改哪里动哪里,不会牵一发而动全身。

第二个就是父子通信的核心逻辑其实特别朴素:数据向下传,事件向上走。别总想着走捷径直接改父组件的数据,省了几行代码,以后埋的坑全要自己填。

第三个,处理引用类型的状态,永远记得返回新的。展开运算符、map、filter 用起来,比起找半天界面不更新的 bug,这点性能开销根本不算事。

当然也不是说这种写法就万能。如果以后项目做大了,组件嵌套五六层,要把一个方法从顶层传到最底层,中间每层都要接一下 props,那也挺崩溃的。那时候就该上 Context 或者 Zustand、Redux 这类状态管理库了。

但就像 Todo List 这样的小功能,甚至很多中小型项目,原生的 props 通信完全够用。别上来就整一堆状态管理库,过度设计也是种病。

你们刚学 React 的时候,有没有踩过直接修改 state 的坑?或者有别的什么经典入门 bug?评论区聊聊,我也找点平衡,看看不是我一个人这么菜。


写了三遍 Todo List,我终于搞懂了 React 父子组件到底怎么通信》 是转载文章,点击查看原文


相关推荐


拼多多笔试真题-多多的审批链(C++/Py/Java /Js/Go)
无限码力2026/7/20

多多的审批链 拼多多技术岗 4月26号笔试 第四题 题目内容 多多的部门中发起审批单有一套审批流程,审批关系可以抽象为一棵以 111 号节点为根的树,共有 nnn 个节点。对于每个 i(2≤i≤n)i(2 \le i \le n)i(2≤i≤n),给定它的直属上级 pip_ipi​,即审批树中存在一条从 pip_ipi​ 到 iii 的边。 对于任意节点 uuu,如果它发起一张审批单,那么审批单只能先提交给它的直属上级,再继续逐级上报到更高层。 现在多多最多可以选择 kkk 个节点作为“关键


图片是 Web 性能监控的重灾区:LCP 和 CLS 到底怎么测、怎么定位到具体那张图
谙忆10242026/7/12

做前端性能这几年,我踩过一个反复出现的坑:本地 Lighthouse 跑出来 95 分,一到线上真实用户那边,投诉页面"卡""跳"的却一堆。后来盯着数据看明白了——问题几乎都出在图片上,而且实验室环境根本复现不出来。 这篇就把"图片相关的 Web 性能怎么测、怎么监控、怎么定位到罪魁祸首那张图"讲清楚。重点是测量和监控这条链路,不是又一篇"图片懒加载十种写法"。 先分清两组概念:实验室数据 vs 真实用户数据 这是很多人一上来就混的地方,先掰开。 实验室数据(Lab):Lighthouse、We


别再只会 if err != nil:Go error 从错误链到工程实战详解
唐青枫2026/7/4

简介 Go 代码里最常见的错误处理大概是这样: result, err := doSomething() if err != nil { return err } 这几行代码不难,真正容易出问题的是后面的选择: 应该新建错误,还是包装原错误? 应该使用 ==,还是 errors.Is? 什么时候需要自定义错误类型? 错误应该在哪一层记录日志? 多个清理操作同时失败,应该返回哪一个错误? 普通错误、panic 和 recover 到底怎么分工? Go 没有把错误处理藏进异常机制,而是把错误当


Java 虚拟线程实战指南:从 Thread API 到 Spring Boot 高并发应用
唐青枫2026/6/26

简介 虚拟线程的英文名是 Virtual Thread,它是 Project Loom 带来的轻量级线程实现。 虚拟线程在 JDK 19、JDK 20 中经历了两轮预览,到了 JDK 21 正式发布。 简单理解: 平台线程:Java 线程长期绑定操作系统线程 虚拟线程:大量 Java 线程由 JVM 调度到少量操作系统线程上 传统 Java 服务经常采用“一请求一线程”的处理方式。 代码很直观,但平台线程数量有限。当大量请求都在等待数据库、HTTP 接口、文件或消息队列时,线程本身会先成为瓶颈


Java Flyway 实战指南:用 SQL 脚本管理数据库版本
唐青枫2026/6/17

简介 Flyway 是一个数据库迁移工具。 它解决的问题和 Liquibase 类似: 数据库结构怎么跟着项目版本一起演进。 不过 Flyway 的风格更简单直接。 它主要通过 SQL 文件管理数据库变更。 比如: V1__create_users_table.sql V2__add_user_email_column.sql V3__create_orders_table.sql V4__insert_init_data.sql 应用启动或命令执行时,Flyway 会检查哪些脚本已经执行过


AI 代理只会在本地打转?我用 MCP 给它接上手脚,3 步接通第一个外部服务
大鹏AI教育2026/6/10

AI 代理只会在本地打转?我用 MCP 给它接上手脚,3 步接通第一个外部服务 先说结论:很多人觉得自己的 AI 代理"不够聪明",其实它不笨,是够不着外面的世界——能读本地文件、能跑命令,却连不上你的数据库、内部接口、第三方服务。我一开始也卡在这儿,把 MCP 跑通后才明白:问题从来不在模型,在它有没有"手脚"。 这篇我把给 OpenClaw 小龙虾(Claude Code 同款)接第一个 MCP 服务的过程讲一遍,连我踩的三个坑和边界判断一起给你。 1. 真问题:AI 写得出脚本,却发不出请


前端跨域完全指南:从 JSONP 到 Nginx 反向代理,一次性彻底搞懂
不会敲代码12026/6/2

前端跨域完全指南:从 JSONP 到 Nginx 反向代理,一次性彻底搞懂 同源策略是浏览器最坚实的护城河,而跨域方案就是一道道精心设计的城门。 前言 前后端分离开发早已成为标配。前端跑 localhost:5173,后端跑 localhost:3000,端口不同,跨域就来了。再加上调用第三方 API、对接合作商接口,跨域问题几乎是每个前端开发者的必修课。 这篇文章从「为什么会有跨域」出发,一次性梳理 JSONP、CORS、WebSocket、postMessage、Vite Proxy、N


详解MySQL事务(超详细版)
一条泥憨鱼2026/5/25

🌈个人主页:一条泥憨鱼(欢迎各位大佬莅临) 🎬精选专栏:数据结构与算法,JavaSE ,苍穹外卖日记 前言: “事务(Transaction)”是数据库开发里非常重要的知识。 简单来说: 事务就是“一组操作,要么全部成功,要么全部失败”。 它主要用于: 转账 下订单 库存扣减 支付系统 多表更新 这些场景都不能只执行一半,否则数据就会出错。 一、为什么需要事务? 先看一个经典案例: 银行转账 假设: 张三账户:1000 元


OpenClaw梦境系统使用介绍
handsomestWei2026/5/3

OpenClaw梦境系统使用介绍 全文链接:OpenClaw梦境系统使用介绍 本文整理 OpenClaw 2.x / 2.5 路线上围绕 Dream Engine(梦境 / 记忆抽象系统) 的能力划分、工作流、指令与场景示例。安装方式、子命令与频道行为会随版本迭代变化,以当前环境 openclaw --help 与官方文档为准。 一、2.x 新功能概览 功能模块关键改进使用上的直接收益① Dream Engine(记忆 / 梦境系统)引入 Dream 概念:对原始 Memor


DeepSeek-V4-Pro 写代码到底行不行?我拿 GLM-5.1 跟它硬碰硬比了一轮
孟健AI编程2026/4/24

大家好,我是孟健。 DeepSeek-V4-Pro 发了,官方说代码能力大幅升级。这种话我听得多了,每次新模型发布都这么说。 但我确实好奇:V4 在写代码这件事上,到底有没有追上 GLM-5.1? GLM-5.1 是我日常写代码的主力模型,用了几个月了,它什么水平我心里有数。所以这次我不跑 benchmark,不拼跑分,就拿我实际工作中的四个场景,让两个模型正面硬刚。 四个场景:源码分析、功能实现、大文件拆分、项目架构分析。 最后再算笔账,看看成本谁更划算。 场景一:项目分析,分析 Claude

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读