用 TypeScript + React Hooks 构建一个健壮的 Todo 应用

29 阅读2分钟

最近我用 React 和 TypeScript 从零写了一个 Todo 应用,整个过程让我深刻体会到:类型系统不是束缚,而是保护。它让组件之间的协作更清晰,状态管理更可靠,连 localStorage 的读写都变得安全可控。下面分享我是怎么一步步搭建这个小项目的。

核心思路:状态集中 + 类型约束

我把所有 todo 数据和操作逻辑封装在一个自定义 Hook useTodos 里,这样组件只需要“消费”状态和方法,不用关心实现细节。同时,用 TypeScript 接口明确约定数据结构,避免传错参数或访问不存在的属性。

首先定义 Todo 的结构:

// types/todo.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

这个接口就像一份契约——任何地方使用 Todo 数据,都必须包含这三个字段,且类型固定。

自定义 Hook:useTodos

这是整个应用的核心。它用 useState 管理状态,并通过 useEffect 同步到 localStorage:

// hooks/useTodos.ts
export function useTodos() {
  const [todos, setTodos] = useState<Todo[]>(() => 
    getStorage<Todo[]>(STORAGE_KEY, [])
  );

  useEffect(() => {
    setStorage<Todo[]>(STORAGE_KEY, todos);
  }, [todos]);

  const addTodo = (title: string) => {
    const newTodo: Todo = {
      id: +new Date(),
      title,
      completed: false
    };
    setTodos([...todos, newTodo]);
  };

  // toggleTodo 和 removeTodo 略...

  return { todos, addTodo, toggleTodo, removeTodo };
}

注意这里用了泛型函数 getStorage<T>setStorage<T>,确保读写 localStorage 时类型安全:

// utils/storages.ts
export function getStorage<T>(key: string, defaultValue: T): T {
  const item = localStorage.getItem(key);
  return item ? JSON.parse(item) : defaultValue;
}

这样,即使从 localStorage 读出来的数据是字符串,TS 也能正确推断为 Todo[] 类型。

组件通信:靠 Props 接口对齐

父组件 App 只负责组合,不处理逻辑:

// App.tsx
export default function App() {
  const { todos, addTodo, toggleTodo, removeTodo } = useTodos();
  return (
    <div>
      <h1>TodoList</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList todos={todos} onToggle={toggleTodo} onRemove={removeTodo} />
    </div>
  );
}

子组件通过 Props 接口明确声明自己需要什么:

// components/TodoList.tsx
interface Props {
  todos: Todo[];
  onToggle: (id: number) => void;
  onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onToggle={onToggle} onRemove={onRemove} />
      ))}
    </ul>
  );
};

这样,如果我在 App 里不小心传了错误类型的 onToggle,TypeScript 会立刻报错,而不是等到运行时才发现按钮点不动。

单个 Todo 项:细节处理

TodoItem 中,根据 completed 状态动态设置样式:

<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
  {todo.title}
</span>

因为 todo 的类型是 Todo,所以 todo.completed 一定是布尔值,不会出现 undefined 导致样式异常。

输入框:防错处理

TodoInput 还做了空值校验:

const handleAdd = () => {
  if (!value.trim()) return; // 防止添加空任务
  onAdd(value);
  setValue('');
};

配合 TS 的 string 类型,确保传给 onAdd 的一定是字符串,不会意外传入数字或 null。

总结:为什么值得用 TS?

  • 提前暴露问题:写代码时就知道哪里传参错了;
  • 自动文档:鼠标悬停就能看到函数签名和字段说明;
  • 重构安全:改一个接口,所有用到的地方都会提示更新;
  • 团队协作友好:别人看你的组件,一眼就知道要传什么。

这个 Todo 应用虽然简单,但用 TS 写完后,感觉整个项目“稳”了很多。以后再做大项目,我肯定会首选 TypeScript —— 它不是增加负担,而是帮我们写出更干净、更可靠的代码。