ReactでTodoアプリを作る #1 開始と追加/削除

読了 7分

React基礎講座(#1〜#15)を終えた方々のための実戦ビルドシリーズを始めます。最初のプロジェクトはすべてのフレームワーク入門者が一度は作ってみるTodoアプリです。小さく見えてもコンポーネント分解、state管理、イベント、フォーム、リストレンダリング、永続化までReactのほぼすべての基本が自然に溶け込むよい練習テーマです。

5編に分けて段階的に機能を積み上げていきます。

  • #1 開始と追加/削除 ← 今回の記事
  • #2 完了トグルと統計
  • #3 フィルタリング
  • #4 編集機能
  • #5 永続化と締めくくり

要件定義 #

何でも作る前に何を作るかをまず明確に書く習慣が重要です。頭の中だけで転がさず、1〜2行でも書いておいてください。

このシリーズが終わると私たちのアプリは次のことができます。

  • 新しいタスクを入力して追加
  • タスク一覧の表示
  • 項目の削除
  • 項目の完了表示(チェックボックス)
  • 残り件数 / 全件数の表示
  • 全部 / 未完了 / 完了でフィルタリング
  • 一括処理(全部完了、完了項目を削除)
  • 項目のインライン編集
  • リロードしてもデータ維持(localStorage)

今回の記事ではそのうち追加/一覧/削除まで扱います。残りは次の記事で順を追って積み上げていきます。

コンポーネントツリー設計 #

コードを書く前にコンポーネントをどう分けるかもあらかじめ描いておくとよいです。あまり細かく分けようと欲張らず、最初は大きな絵を中心にシンプルに。

コンポーネントツリー (1編基準)
App
└── TodoApp
    ├── TodoForm        — 入力フォーム
    └── TodoList        — 一覧コンテナ
        └── TodoItem    — 個別項目 (繰り返し)

stateはどこに置きましょうか?入力値(TodoForm)はそのフォームだけ知っていればよいのでそこに置き、タスク一覧自体はTodoAppに置きます。フォームが項目を追加したり、項目が自分を削除しようとすると結局同じ一覧を触らなければならないので、#11で学んだstateのリフトアップパターンが自然に適用されます。

プロジェクト開始 #

基礎講座#2で作ったViteプロジェクトがあればそれをそのまま使ってもよいですし、新しく作ってもよいです。

新しいプロジェクトで始める場合
npm create vite@latest todo-app
cd todo-app
npm install
npm run dev

オプションはReact + JavaScriptで選択します。開始後にデフォルトのボイラープレートはすべて消して、空のApp.jsxから始めましょう。

src/App.jsx (初期状態)
function App() {
  return <h1>Todo アプリ</h1>;
}

export default App;

src/App.csssrc/index.cssも空けておくか最小限だけ残しておいてください。今回のシリーズはスタイルをインラインstyleで処理してコードをシンプルに保ちます(実戦ではCSSモジュール、Tailwind、styled-componentsなどを使うでしょうが、今は核心ロジックに集中)。

データの形を決める #

各タスクはどんな情報を持つでしょうか?最低限:

やること(Todo)オブジェクトの形
{
  id: '一意のID',
  text: 'やることの内容',
  completed: false,
}

idcrypto.randomUUID()で作ったUUIDを使います。#8で学んだようにインデックスkeyはアンチパターンですから。

TodoFormを作る #

入力フォームから作ってみましょう。controlled component(#9)で、フォーム送信時に親に新しい項目を知らせる構造です。

src/TodoForm.jsxを新しく作ります。

src/TodoForm.jsx
import { useState } from 'react';

function TodoForm({ onAdd }) {
  const [text, setText] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    const trimmed = text.trim();
    if (!trimmed) return;
    onAdd(trimmed);
    setText('');
  }

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="やることを入力してください"
        style={{ flex: 1, padding: '6px' }}
      />
      <button type="submit" disabled={!text.trim()}>追加</button>
    </form>
  );
}

export default TodoForm;

ポイント:

  • text stateはこのフォームの中だけで使うのでここに置く
  • onAddは親に「新しい項目が入ってきた」と知らせるコールバック(propsで受け取る)
  • text.trim()で空白だけ入力した場合は無視
  • 追加後、入力欄は空にする
  • 空入力時はボタンを無効化

TodoItemを作る #

個別項目のコンポーネントです。一旦テキストと削除ボタンだけある状態で始めます(チェックボックスは#2で、編集機能は#4で追加)。

src/TodoItem.jsx:

src/TodoItem.jsx
function TodoItem({ todo, onDelete }) {
  return (
    <li style={{
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'space-between',
      padding: '8px',
      borderBottom: '1px solid #eee',
    }}>
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)} style={{ marginLeft: '8px' }}>
        削除
      </button>
    </li>
  );
}

export default TodoItem;

onDeleteも親から受け取ります。自分自身を削除する権限はなく(#4のpropsは読み取り専用の原則を覚えていますね?)、「私のIDで削除してください」と親に依頼する形です。

TodoListを作る #

TodoItemたちを描くコンテナです。空の状態のときに案内文も処理します(#7条件付きレンダリング)。

src/TodoList.jsx:

src/TodoList.jsx
import TodoItem from './TodoItem';

function TodoList({ todos, onDelete }) {
  if (todos.length === 0) {
    return <p style={{ color: '#888' }}>やることがありません新しく追加してみましょう</p>;
  }

  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onDelete={onDelete} />
      ))}
    </ul>
  );
}

export default TodoList;

TodoAppでまとめる #

これですべてのピースを集めてみます。state(todos)はここに住み、追加/削除のハンドラもここで定義して子に下ろします。

src/TodoApp.jsx:

src/TodoApp.jsx
import { useState } from 'react';
import TodoForm from './TodoForm';
import TodoList from './TodoList';

function TodoApp() {
  const [todos, setTodos] = useState([]);

  function addTodo(text) {
    const newTodo = {
      id: crypto.randomUUID(),
      text,
      completed: false,
    };
    setTodos(prev => [newTodo, ...prev]);
  }

  function deleteTodo(id) {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
      <h1>Todo</h1>
      <TodoForm onAdd={addTodo} />
      <TodoList todos={todos} onDelete={deleteTodo} />
    </div>
  );
}

export default TodoApp;

核心パターン:

  • setTodos(prev => [newTodo, ...prev]) — 新しい項目を一番上に追加しながら新しい配列を作って渡す(#5で学んだイミュータブル更新)
  • setTodos(prev => prev.filter(todo => todo.id !== id)) — 削除も同様に新しい配列で
  • 関数型更新(prev => ...)を使う理由は#5で扱ったように以前の値を安全に受け取るため

最後にApp.jsxTodoAppをレンダリングします。

src/App.jsx
import TodoApp from './TodoApp';

function App() {
  return <TodoApp />;
}

export default App;

保存してブラウザで確認してみてください。入力欄にタスクを書いて追加ボタンを押すと上に追加され、削除ボタンを押すと消えます。

動作確認チェックリスト #

  • 空の入力は追加されないか
  • 空白だけ入力した場合(" ")も追加されないか
  • 新しい項目が一番上に追加されるか(時間順で最新が上)
  • 同じテキストを2回追加しても別々の項目として入るか(それぞれ異なるUUID)
  • 削除ボタンが正確にその項目だけを削除するか

すべて正しく動作すれば1段階完成です。

データフローを再度見る #

これまで作った構造を一度整理してみてください。

データの流れ
TodoApp (todos state)
  ├─ addTodo / deleteTodo 関数を保持
  ├─ TodoForm
  │   - onAdd={addTodo}    ← 新しい項目を通知
  └─ TodoList
      - todos 表示
      - onDelete={deleteTodo}    ← 削除リクエスト
        └─ TodoItem (各項目)

核心はデータ(todos)は1か所(TodoApp)にだけあり、すべての変更はそこを経由するという点です。TodoFormTodoItemも直接データを触らず、コールバックで「こうしてください」と依頼するだけです。これが#11で学んだ単方向データフローとstateのリフトアップパターンが実戦で動作する姿です。

ヒント
コンポーネントファイルをどこに置くか悩むかもしれません。小さなプロジェクトはsrc/の真下にフラットに置き、増えてきたらsrc/components/Todo/...のようにまとめる方式で段階的に整理するのが一般的です。最初から深いフォルダ構造を作るとかえってコードを探しにくくなります。

おわりに #

今回の記事ではTodoアプリの第一歩を踏み出しました。

  • 要件を書いて、コンポーネントツリーを描いた
  • TodoForm / TodoItem / TodoList / TodoAppに責任を分離した
  • stateは共通の親(TodoApp)にだけ置いてコールバックで変更依頼を受ける単方向フローを作った
  • crypto.randomUUID()で安全なkeyを使った

今、私たちのアプリは追加して削除することしかできません。次の記事「ReactでTodoアプリを作る #2 完了トグルと統計」では、各項目にチェックボックスをつけて完了表示をし、残り件数 / 全件数を表示する統計を追加してみます。

X