🍔 我用 React 写了一个 TodoList,终于搞懂了"状态该放谁家"

写在前面:今天学了一个特别"实战"的东西------用 React 写一个 TodoList。老师说,TodoList 是前端框架的"Hello World"------但千万不要觉得它简单。一个 TodoList 虽然只有 4 个组件、几十行代码,但它涵盖了 React 最核心的知识:组件化、响应式、数据驱动、父子通信、Props 向下传递、事件向上报告...... 尤其是"数据状态该放在哪个组件"这个问题------我第一次写把状态放在了三个子组件里,结果数据一团乱。老师说:"数据状态由父组件统一管理,子组件只负责展示和上报事件。" 我就四个组件,分了三次才搞明白。


一、组件树------先规划再动手

1.1 先画组件树

老师说:

"规划一下组件树(项目的目录结构),有利于 Vibe Coding。"

TodoList 的组件树:

css 复制代码
App(根组件------持有所有数据状态)
├── TodoInput(输入框------新增任务)
├── TodoList(任务列表------展示和操作)
│   └── 每个任务 li(复选框 + 文字 + 删除按钮)
└── TodoStats(统计信息------总数、活跃、已完成)

组件文件结构:

bash 复制代码
src/
├── App.jsx             # 根组件,持有 todos 状态
├── App.css             # 全局样式
├── components/
│   ├── TodoInput.jsx   # 输入框组件
│   ├── TodoList.jsx    # 列表组件
│   └── TodoStats.jsx   # 统计组件

1.2 组件树的好处

老师说:

"一眼看出页面的组件构成、页面的组件化程度、粒度。前端发展的必然------页面及交付越来越复杂,组件作为开发的最小单元,团队好协作、好复用、好维护。"

如果没有组件树,你写代码就是"拉面条"------一个文件越写越长,改都不知道从哪改。


二、数据状态放哪里?父组件统一管理

2.1 错误思路

我之前第一反应是:每个子组件自己管自己的状态。

  • TodoInput 管自己的输入内容。
  • TodoList 管自己的任务列表。
  • TodoStats 管自己的统计数据。

结果------数据全乱套了。 输入框说"我加了新任务",但列表不知道;列表说"我删了任务",但统计不知道。

2.2 正确做法:状态提升

老师说:

"todos JSON 数组。数据状态由父组件管理(持有和修改),子组件们共享统一(Props)的数据状态。数据和界面统一,不能出问题。"

App.jsx------唯一的"数据主人":

jsx 复制代码
const App = () => {
    const [todos, setTodos] = useState(() => [
        { id: 1, text: '吃饭', completed: false },
        { id: 2, text: '睡觉', completed: false },
        { id: 3, text: '打豆豆', completed: false },
    ]);

    // 所有操作状态的方法都在 App 里
    const addTodo = (text) => {
        if (text.trim() === '') return;
        setTodos([
            { id: Date.now(), text, completed: false },
            ...todos,
        ]);
    };

    const toggleTodo = (id) => {
        setTodos(todos.map(todo =>
            todo.id === id
                ? { ...todo, completed: !todo.completed }
                : todo
        ));
    };

    const deleteTodo = (id) => {
        setTodos(todos.filter(todo => todo.id !== id));
    };

    const clearCompleted = () => {
        setTodos(todos.filter(todo => !todo.completed));
    };

    const activeCount = todos.filter(t => !t.completed).length;
    const completedCount = todos.length - activeCount;

    return (
        <div>
            <h1>My Todo List</h1>
            {/* Props 向下传数据,自定义事件向上报告 */}
            <TodoInput onAdd={addTodo} />
            <TodoList
                todos={todos}
                onToggle={toggleTodo}
                onDelete={deleteTodo}
            />
            <TodoStats
                total={todos.length}
                active={activeCount}
                completed={completedCount}
                onClearCompleted={clearCompleted}
            />
        </div>
    );
};

数据的流向是这样的:

markdown 复制代码
App(状态主人)
  ├── Props 向下 → TodoInput(只展示输入框)
  │     ↑ 自定义事件向上报告(onAdd)
  │
  ├── Props 向下 → TodoList(只负责渲染列表)
  │     ↑ 自定义事件向上报告(onToggle, onDelete)
  │
  └── Props 向下 → TodoStats(只负责显示统计)
        ↑ 自定义事件向上报告(onClearCompleted)

核心原则:子组件不可以直接修改父组件的数据状态,只能通过自定义事件通知父组件。父组件修改后,子组件自动更新。


三、父子组件通信:Props 向下,事件向上

3.1 TodoInput:只负责输入

jsx 复制代码
const TodoInput = ({ onAdd }) => {
    const [inputValue, setInputValue] = useState('');

    const handleSubmit = (e) => {
        e.preventDefault();
        onAdd(inputValue);  // 通知父组件
        setInputValue('');   // 清空输入框
    };

    return (
        <form className="todo-input" onSubmit={handleSubmit}>
            <input
                type="text"
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
                placeholder="What needs to be done?"
                autoFocus
            />
            <button type="submit">Add</button>
        </form>
    );
};

TodoInput 有自己的状态 inputValue------这个状态是它自己独有的,不需要别人共享。所以放在自己组件里是合理的。

当用户提交时,它通过 onAdd(inputValue) 把新任务"报告"给父组件。

3.2 TodoList:只负责展示和触发操作

jsx 复制代码
const TodoList = ({ todos, onToggle, onDelete }) => {
    return (
        <ul className="todo-list">
            {todos.length === 0 ? (
                <li className="empty">No todos yet!</li>
            ) : (
                todos.map(todo => (
                    <li key={todo.id} className={todo.completed ? 'completed' : ''}>
                        <label>
                            <input
                                type="checkbox"
                                checked={todo.completed}
                                onChange={() => onToggle(todo.id)}
                            />
                            <span>{todo.text}</span>
                        </label>
                        <button onClick={() => onDelete(todo.id)}>Delete</button>
                    </li>
                ))
            )}
        </ul>
    );
};

TodoList 没有自己的状态 ------它只接收 todos Props,然后渲染。用户点击 checkbox 或删除按钮时,调用父组件传进来的 onToggleonDelete

3.3 TodoStats:只负责显示

jsx 复制代码
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
    return (
        <div>
            <p>Total: {total} | Active: {active} | Completed: {completed}</p>
            {completed > 0 && (
                <button onClick={onClearCompleted} className="clear-btn">
                    Clear Completed
                </button>
            )}
        </div>
    );
};

TodoStats 也没有自己的状态 ------所有数据都从 Props 来。activeCountcompletedCount 是在父组件 App 里通过 todos.filter() 计算出来的。

老师说:

"子组件主要负责展示------父组件给我什么 Props,我显示成什么样子。报告父组件,才可以修改。"


四、状态管理的"金法则"

4.1 状态放哪的判断标准

使用场景 状态放哪 例子
只有本组件用 本组件 TodoInputinputValue
多个子组件共享 最近的共同父组件 Apptodos
需要计算得到 不放 state,直接计算 activeCountcompletedCount

4.2 不可变数据

注意 addTodotoggleTodo 的实现:

jsx 复制代码
// ✅ 正确的做法------返回全新数组
setTodos([
    { id: Date.now(), text, completed: false },
    ...todos,
]);

setTodos(todos.map(todo =>
    todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
));

每次更新都返回一个新数组,而不是修改原数组。 这是 React 的"不可变性"原则------React 通过比较新旧引用来判断是否需要重新渲染。


五、自定义事件:父组件传给子组件的"遥控器"

老师说:

"子组件是不可以直接修改父组件的数据状态的,只能通过自定义事件通知父组件,父组件修改后,子组件自动更新。"

自定义事件本质上就是 Props 里的函数:

ini 复制代码
父组件:<TodoInput onAdd={addTodo} />
                ↑ 把 addTodo 函数传下去

子组件:const TodoInput = ({ onAdd }) => { ... }
                                   ↑ 接收函数

子组件调用:onAdd(inputValue)
             ↑ 就像按了遥控器------父组件收到通知

Props 可以传递任何东西------数据(数字、字符串、数组)、函数(事件处理)、甚至是组件。


六、总结:TodoList 里的 React 核心知识

概念 在这个项目里怎么体现的
组件化 4 个组件:App、TodoInput、TodoList、TodoStats
响应式 useState → 数据变了界面自动更新
数据驱动 不再操作 DOM,只操作数据状态
Props 向下 todos 从 App 流向 TodoList 和 TodoStats
事件向上 onAddonToggleonDelete 从子组件报告到父组件
状态提升 todos 在 App 统一管理,子组件共享
派生状态 activeCountcompletedCount 直接计算,不存 state
不可变数据 每次 setTodos 都传新数组,不修改原数组
条件渲染 todos.length === 0 ? 显示空状态

TodoList 虽小,五脏俱全。它包含了 React 中 90% 的核心概念。


写在最后

今天最大的收获,是真正理解了"状态提升"和"父子通信"。以前写 React 总是随心所欲------这个组件的状态放这里,那个组件的状态放那里------结果数据乱七八糟。现在知道了:多子组件共享的状态,一定要放到共同的父组件去管理。子组件只负责展示和上报事件。

下次面试官问你:"React 中父子组件怎么通信?"

你可以淡定地说:

"React 的父子通信遵循'Props 向下,事件向上'的原则。父组件通过 Props 把数据传给子组件,子组件通过调用父组件传进来的函数(自定义事件)来报告操作。子组件不能直接修改父组件的状态。多个子组件需要共享的状态,应该提升到最近的共同父组件中管理。这样数据流是单向的、可预测的,方便调试和维护。"

然后看着面试官满意的表情,心里默念:这波,又稳了。


本文所有代码示例均来自课堂学习资料,真实可运行。

相关推荐
FogLetter1 小时前
我真的写了个“诈尸式”缓存组件:手撕React KeepAlive
前端·react.js·面试
A24207349301 小时前
Vue.js 初学者注意事项与项目开发实践指南
前端·javascript·vue.js
用户059540174461 小时前
「记仇」记忆存储测试踩坑实录:用 Pytest+Docker 把 BUG 扼杀在凌晨 3 点前
前端·css
dllmayday2 小时前
Bootstrap三开关样式
前端·javascript·bootstrap
杨先生哦2 小时前
【2026热端攻防系列 11/12】前端AI风控攻防实战:验证码缺陷、人机验证绕过、智能爬虫对抗与企业智能风控加固方案
前端·人工智能·笔记·爬虫·安全
CodeSheep2 小时前
hvv员工家属爆料:老公22年入职的,普通员工,老是被误以为高薪多奖金,实际上还没配股,暂时钱没存多少,我倒是很心疼他工作辛苦。
前端·后端·程序员
IT_陈寒2 小时前
Redis这个内存杀手坑惨了我,排查三小时发现是过期策略搞的鬼
前端·人工智能·后端
带娃的IT创业者2 小时前
PostHog 的 TypeScript 原生移植:一场开源产品工程化的自我革命
javascript·typescript·开源·开源软件·posthog·技术重构
人间凡尔赛2 小时前
React Compiler 正式落地一年:告别手动 useMemo/useCallback 的全栈实践
前端·性能优化·react