用 TypeScript 写一个 Todo 应用:自定义 Hook 实战
上一篇我们讲了 useContext 和自定义 Hook 的封装思路。这次上点实战强度:用 TypeScript 从零写一个 Todo 应用,把状态、类型和业务逻辑组织在一起。
这篇文章不会把所有代码都堆在一个 App.tsx 里,而是把 Todo 的核心行为封装进 useTodos,让组件主要负责渲染和用户交互。
你会看到:
- 如何用
interface和联合类型描述 Todo 数据 - 如何为自定义 Hook 设计清晰的输入和输出
- 添加、切换、删除、清除已完成任务时,如何进行不可变更新
- 为什么 Hook 能复用逻辑,但不会自动共享状态
- 如何把一个 Hook 接入真正可运行的 Todo 页面
一、先设计类型:Todo 到底有哪些数据?
写功能之前,先把数据结构想清楚。一个最小 Todo 至少需要三个字段:
// types/todo.ts
export interface Todo {
id: string;
text: string;
completed: boolean;
}
export type FilterType = "all" | "active" | "completed";
Todo 是对象结构,所以使用 interface 很自然;FilterType 是一组有限的字符串选项,使用字符串联合类型更合适。
let filter: FilterType = "active"; // 正确
filter = "deleted"; // TypeScript 报错
interface 和 type 怎么选?
不要把它们理解成绝对规则:两者都能描述对象,也都能参与类型组合。日常开发中可以这样记:
| 类型 | 更常见的用途 |
|---|---|
interface | 描述对象形状、组件 props、可扩展的实体结构 |
type | 联合类型、交叉类型、元组、函数类型和类型别名 |
例如筛选按钮的文案可以借助 Record 获得完整约束:
const filterLabels: Record<FilterType, string> = {
all: "全部",
active: "未完成",
completed: "已完成",
};
如果以后给 FilterType 新增一个值,filterLabels 没有同步补充时,TypeScript 会提醒你。这就是类型系统和业务规则之间的连接。
二、先确定 Hook 的职责边界
useTodos 不应该负责按钮长什么样,也不应该直接操作 DOM。按照当前这份源码,它主要负责三类事情:
- 保存 Todo 列表和筛选状态
- 提供添加、切换、删除和清除任务的方法
- 返回组件渲染所需的数据和操作函数
组件只需要拿到这些数据和方法:
const {
todos,
addTodo,
toggleTodo,
deleteTodo,
clearCompleted,
} = useTodos();
这个返回对象就是 Hook 的公开接口。以后把数据从内存改成 localStorage 或服务端 API,组件依然可以保持相同的调用方式。
三、封装 useTodos:Todo 应用的核心逻辑
这一节直接对应你的 useTodos.ts 源码。我们按“状态 → 操作 → 返回值”分开阅读,所有代码片段都属于同一个 Hook,不需要一次面对一大段完整实现。
1. 先搭出状态骨架
// hooks/useTodos.ts
import { useState } from "react";
import type { FilterType, Todo } from "../types/todo";
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<FilterType>("all");
// 业务方法写在这里
}
这里有两份状态:todos 是任务列表,filter 是筛选条件。当前源码已经预留了 filter,但还没有实现筛选按钮和 setFilter,后面会把它作为扩展说明。
2. 添加任务
先实现添加任务。输入先 trim,再判断是否为空:
const addTodo = (text: string) => {
const normalizedText = text.trim();
if (!normalizedText) return false;
const newTodo: Todo = {
id: Date.now().toString(),
text: normalizedText,
completed: false,
};
setTodos((previousTodos) => [...previousTodos, newTodo]);
return true;
};
空白内容直接返回,不创建无意义的 Todo。这里返回 false 或 true,组件可以据此决定是否清空输入框。
3. 切换完成状态
const toggleTodo = (id: string) => {
setTodos((previousTodos) =>
previousTodos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
};
map 会返回新数组,只有匹配到的 Todo 通过展开运算符创建新对象,其他项继续复用原对象。
4. 删除和清除
const deleteTodo = (id: string) => {
setTodos((previousTodos) =>
previousTodos.filter((todo) => todo.id !== id)
);
};
const clearCompleted = () => {
setTodos((previousTodos) =>
previousTodos.filter((todo) => !todo.completed)
);
};
两个操作都使用 filter,区别只是保留条件不同:一个保留 ID 不匹配的项,一个保留未完成的项。
5. 暴露 Hook 接口
最后把状态和操作方法返回给组件:
return {
todos,
filter,
addTodo,
toggleTodo,
deleteTodo,
clearCompleted,
};
至此,前面的代码片段合在一起就是当前版本的 useTodos。代码短了,但每个片段的职责更明确。
还有两个细节值得记住:
- 当新状态依赖旧状态时,优先使用
setTodos(previous => next),避免依赖过期的渲染快照。 - 当前源码中的
filter只是功能预留;没有setFilter和筛选后的列表时,组件无法真正切换筛选条件。
当前项目使用 Date.now().toString() 生成 ID,作为学习项目足够直观;如果未来存在短时间批量创建任务、离线同步或服务端写入等场景,再统一替换为 crypto.randomUUID() 或后端生成的 ID。
四、理解不可变更新:不要直接改 state
React state 应该被当作不可变数据处理。下面的写法不要使用:
// ❌ 直接修改原对象和原数组
todos[0].completed = true;
setTodos(todos);
问题不只是“页面可能不更新”。React 以及 memo、依赖比较等机制都依赖引用变化来判断数据是否改变。直接修改原对象会破坏这个约定,也可能让旧的渲染快照被悄悄改变。
正确写法是:只复制发生变化的那一层。
// ✅ 生成新数组,并为变化的 Todo 生成新对象
setTodos((previousTodos) =>
previousTodos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
四种常见操作可以这样记:
| 操作 | 不可变模式 | 示例 |
|---|---|---|
| 添加 | 展开旧数组,再追加新元素 | [...previousTodos, newTodo] |
| 更新 | map 创建新数组,展开目标对象 | todo.id === id ? { ...todo, completed: true } : todo |
| 删除 | filter 创建新数组 | previousTodos.filter((todo) => todo.id !== id) |
| 批量删除 | filter 保留需要留下的元素 | previousTodos.filter((todo) => !todo.completed) |
“不可变”不等于深拷贝所有数据,而是:不修改当前 state 引用,返回新的状态引用。
五、把 Hook 接入 Todo 页面
页面组件只需要接收 Hook 返回的数据和方法,负责组织输入框、列表和按钮:
import { useTodos } from "./hooks/useTodos";
export default function TodoApp() {
const { todos, addTodo, toggleTodo, deleteTodo, clearCompleted } = useTodos();
return (
<main>
<TodoInput onAdd={addTodo} />
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
<button type="button" onClick={clearCompleted}>清除已完成</button>
</main>
);
}
这段代码体现了一个很重要的分工:
TodoInput保存输入框文本,因为它只属于当前表单 UIuseTodos保存 Todo 数据和业务操作TodoInput负责表单提交,TodoList负责遍历任务- 点击事件通过
toggleTodo、deleteTodo把用户操作交给 Hook
组件并不是完全没有逻辑,而是只保留展示和交互所需的逻辑;数据如何更新,则集中在 Hook 里。当前版本还没有接入筛选 UI,因此列表直接使用 todos。
六、为什么自定义 Hook 能复用逻辑?
普通函数只能复用普通计算:
function formatTodoCount(count: number) {
return `剩余 ${count} 项`;
}
自定义 Hook 可以复用完整的 React 响应式逻辑:
function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
// state、事件处理、推导数据和 effect 都可以集中在这里
return { todos, addTodo, toggleTodo };
}
如果另一个页面也调用 useTodos(),它会复用同一套业务代码,但默认拥有自己独立的 Todo 状态:
const firstList = useTodos();
const secondList = useTodos();
这两个调用不会自动共享数据。想让多个组件共享同一份 Todo 列表,需要把 Hook 放进共同的 Provider,或者把状态提升到共同父组件。
这也是自定义 Hook 和 Context 的区别:
| 能力 | 自定义 Hook | Context |
|---|---|---|
| 复用状态逻辑 | 可以 | 不负责逻辑复用本身 |
| 默认是否共享同一份状态 | 不共享,每次调用独立 | Provider 子树共享 value |
| 适合解决的问题 | 抽取状态、effect 和事件逻辑 | 跨层级传递共享数据 |
两者组合起来,就能得到“共享状态 + 可复用访问入口”。
七、可以继续怎样扩展?
这个版本只把数据保存在内存里,刷新页面后 Todo 会消失。后续可以逐步增加能力:
1. 增加编辑 Todo
function updateTodo(id: string, text: string) {
const normalizedText = text.trim();
if (!normalizedText) return false;
setTodos((previousTodos) =>
previousTodos.map((todo) =>
todo.id === id ? { ...todo, text: normalizedText } : todo
)
);
return true;
}
2. 持久化到 localStorage
这时可以在 Hook 里增加 useEffect,监听 todos 并保存;初始化 state 时从 localStorage 读取。要注意 JSON 解析失败、数据格式不符合 Todo 类型等异常情况,TypeScript 不会替你验证运行时 JSON。
3. 使用 useReducer 管理复杂操作
当新增、编辑、批量操作越来越多时,可以把多个 setter 改成一个 reducer:
type TodoAction =
| { type: "add"; text: string }
| { type: "toggle"; id: string }
| { type: "delete"; id: string }
| { type: "clearCompleted" };
这样每个业务动作都有清晰的 type 和 payload,适合状态转换复杂的场景。
八、常见误区
误区一:TypeScript 会在运行时校验接口
不会。interface Todo 只在编译阶段帮助编辑器和 TypeScript 检查代码,接口会在编译后消失。
从接口、localStorage 或用户输入拿到的数据,仍然需要运行时校验。类型声明不能替代数据验证。
误区二:修改数组后再 setTodos 就算不可变更新
下面这种写法仍然修改了旧数组:
todos.push(newTodo);
setTodos(todos);
即使调用了 setter,旧引用已经被污染。应该返回新数组:
setTodos((previousTodos) => [...previousTodos, newTodo]);
误区三:把筛选结果也保存成 state
后续实现筛选时,筛选结果可以由 todos 和 filter 推导出来,不必再用 effect 同步一份:
const visibleTodos = todos.filter(/* ... */);
重复保存派生数据,会增加状态同步的分支和出错机会。当前源码还没有实现这一步,所以只保留了 filter 状态作为扩展入口。
误区四:只要抽成 Hook,就会自动共享状态
不会。Hook 复用的是逻辑,不是某一次调用产生的状态。共享状态仍然需要 Context、状态提升或专门的状态管理方案。
九、面试怎么回答?
可以这样回答:
自定义 Hook 是一个以
use开头的函数,用来复用包含 React Hooks 的逻辑。它可以封装 state、effect、事件处理和清理逻辑,让组件更专注于 UI。每次调用自定义 Hook 都会创建属于当前组件实例的独立状态;如果多个组件需要共享同一份状态,还需要结合状态提升、Context 或状态管理库。TypeScript 则可以在编译阶段约束 Todo 数据结构、筛选值和组件事件参数,减少类型错误。
十、总结
| 层次 | 负责什么 | 本项目中的内容 |
|---|---|---|
types/ | 描述数据和业务选项 | Todo、FilterType、TodoAction |
hooks/ | 封装状态和业务逻辑 | useTodos:添加、切换、删除、筛选 |
components/ | 接收 props 并渲染 UI | TodoItem |
| 页面组件 | 组织交互和页面结构 | TodoApp |
这篇文章可以浓缩成三句话:
- TypeScript 先描述数据结构,再让代码围绕类型协作
- 自定义 Hook 把 state、事件和副作用组织成可复用逻辑
- React state 按不可变方式更新,派生数据尽量在渲染阶段计算
真正好用的自定义 Hook,不是把代码从组件里挪到另一个文件,而是让 Hook 拥有清晰的职责、稳定的返回接口和可验证的业务行为。