Todoアプリは、Reactの基本を一通り確認する題材として扱いやすい例です。入力欄、一覧、完了状態、削除、件数表示、ブラウザへの保存まで実装すると、単純な画面の中にstate管理、コンポーネント通信、副作用、フォーム制御といった要素がそろいます。

この記事では、Viteで作成した小さなReactアプリを四つのコンポーネントへ分け、次の内容を整理します。

  • useStateの遅延初期化
  • 配列を直接変更しないstate更新
  • propsとコールバックによる単方向データフロー
  • useEffectの依存配列とクリーンアップ
  • localStorageを使ったデータの永続化
  • CSS変数とprefers-color-schemeによるテーマ切り替え

単に動くコードを並べるのではなく、どの責務をどこへ置くと状態の流れを追いやすくなるかを見ていきます。

プロジェクト構成

ViteのReactテンプレートを基礎に、Todoアプリを次の構成へ分けます。

src/
├── main.jsx
├── App.jsx
├── App.css
├── index.css
└── components/
    ├── TodoInput.jsx
    ├── TodoList.jsx
    └── TodoStats.jsx

役割は次の通りです。

コンポーネント主な責務保持するstate
AppTodo一覧の管理、保存、操作関数の提供todos
TodoInput入力値とフォーム送信inputValue
TodoListTodoの表示、完了切り替え、削除操作なし
TodoStats件数の表示、完了済みTodoの一括削除なし

コンポーネントは細かく分ければよいわけではありません。今回のように、入力、一覧、集計で責務と変更理由が分かれる場合は、分割によって各ファイルの意図を説明しやすくなります。

エントリーポイント

main.jsxでは、createRootでReactアプリをDOMへ描画します。

// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";

const rootElement = document.getElementById("root");

if (!rootElement) {
  throw new Error("#root element was not found");
}

createRoot(rootElement).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

StrictModeは開発中に追加のチェックを有効にします。開発環境ではコンポーネントのレンダーやEffectのセットアップとクリーンアップが追加で実行されることがありますが、本番環境で同じ回数だけ実行されるという意味ではありません。

Viteの設定は、Reactプラグインを読み込む最小構成で始められます。

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
});

コンポーネントツリーと単方向データフロー

Todo一覧はAppが保持し、子コンポーネントへ必要な値と操作関数を渡します。

App
├── TodoInput
│   └── onAdd
├── TodoList
│   ├── todos
│   ├── onToggle
│   └── onDelete
└── TodoStats
    ├── total
    ├── active
    ├── completed
    └── onClearCompleted

データの流れは次のようになります。

  1. Apptodosをstateとして保持する
  2. TodoListはpropsでtodosを受け取る
  3. 利用者がチェックボックスを操作する
  4. TodoListonToggle(id)を呼ぶ
  5. Apptodosを更新する
  6. 新しいpropsを受け取ったTodoListが再レンダーされる

子コンポーネントは親のstateを直接変更しません。変更を要求するコールバックを呼び、stateを所有するコンポーネントが更新を担当します。

AppによるTodo一覧の管理

まず、localStorageから安全に初期値を読み込む関数を用意します。

const storageKey = "react-todos";

function readStoredTodos() {
  try {
    const storedValue = localStorage.getItem(storageKey);

    if (!storedValue) {
      return [];
    }

    const parsedValue = JSON.parse(storedValue);
    return Array.isArray(parsedValue) ? parsedValue : [];
  } catch {
    return [];
  }
}

保存内容が壊れていた場合、JSON.parse()は例外を投げます。読み込み時に失敗しても画面全体を停止させないため、この例では空配列へ戻しています。

useStateの遅延初期化

useStateへ値ではなく関数を渡すと、その関数は初期化時に呼ばれます。

const [todos, setTodos] = useState(readStoredTodos);

次のように関数を実行した結果を渡すと、コンポーネント関数が呼ばれるたびにreadStoredTodos()自体は実行されます。

const [todos, setTodos] = useState(readStoredTodos());

初期値の計算にストレージ読み込みや大きなデータ変換が含まれる場合は、遅延初期化によって不要な実行を避けられます。

ただし、localStorageはブラウザAPIです。この例はクライアントだけで動くViteアプリを想定しています。SSRを利用する構成では、サーバー上にlocalStorageが存在しないため、クライアント側で読み込む境界を別途設計する必要があります。

state配列の不変更新

Reactのstateに保存した配列は、読み取り専用として扱います。追加、更新、削除では、元の配列を直接変更せず、新しい配列をsetTodosへ渡します。

また、更新後の値が現在のstateに依存するため、関数形式の更新を使います。

const addTodo = (text) => {
  const normalizedText = text.trim();

  if (!normalizedText) {
    return;
  }

  setTodos((currentTodos) => [
    {
      id: crypto.randomUUID(),
      text: normalizedText,
      completed: false,
    },
    ...currentTodos,
  ]);
};

完了状態の切り替えにはmap()を使います。

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

削除にはfilter()を使います。

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

完了済みのTodoをまとめて削除する場合も同じです。

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

直接push()したり、todo.completedを書き換えたりすると、既存のstateを破壊します。新しい配列と、変更対象だけを複製した新しいオブジェクトを作ることで、更新前後の値を明確に分けられます。

派生値と冗長なstate

未完了件数と完了件数は、todosからレンダー中に計算できます。

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

これらを別のstateとして保存すると、Todo一覧と件数を同期し続ける必要が生まれます。既存のpropsやstateから計算できる値は、まずレンダー中に求める方法を検討します。

localStorageへの永続化

todosが変化したら、最新の配列をlocalStorageへ書き込みます。

useEffect(() => {
  try {
    localStorage.setItem(storageKey, JSON.stringify(todos));
  } catch (error) {
    console.warn("Todoを保存できませんでした", error);
  }
}, [todos]);

このEffectは、Reactのstateとブラウザのストレージという外部システムを同期しています。追加、切り替え、削除の各イベントハンドラへ保存処理を重複して書かず、todosの変更を一か所で監視できます。

実運用では、ストレージ容量の上限、保存禁止の環境、複数タブ間の同期、データ形式の変更も考慮が必要です。機密情報や大きなデータを無条件にlocalStorageへ保存する設計には向きません。

Appコンポーネントの全体像

ここまでの処理をまとめると、App.jsxは次のようになります。

// src/App.jsx
import { useEffect, useState } from "react";
import TodoInput from "./components/TodoInput.jsx";
import TodoList from "./components/TodoList.jsx";
import TodoStats from "./components/TodoStats.jsx";
import "./App.css";

const storageKey = "react-todos";

function readStoredTodos() {
  try {
    const storedValue = localStorage.getItem(storageKey);

    if (!storedValue) {
      return [];
    }

    const parsedValue = JSON.parse(storedValue);
    return Array.isArray(parsedValue) ? parsedValue : [];
  } catch {
    return [];
  }
}

export default function App() {
  const [todos, setTodos] = useState(readStoredTodos);

  useEffect(() => {
    try {
      localStorage.setItem(storageKey, JSON.stringify(todos));
    } catch (error) {
      console.warn("Todoを保存できませんでした", error);
    }
  }, [todos]);

  const addTodo = (text) => {
    const normalizedText = text.trim();

    if (!normalizedText) {
      return;
    }

    setTodos((currentTodos) => [
      {
        id: crypto.randomUUID(),
        text: normalizedText,
        completed: false,
      },
      ...currentTodos,
    ]);
  };

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

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

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

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

  return (
    <main className="todo-app">
      <h1>Todo</h1>

      <TodoInput onAdd={addTodo} />

      <TodoList
        todos={todos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />

      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </main>
  );
}

TodoInput:入力欄の局所state

入力途中の文字列はTodoInputだけが必要とするため、子コンポーネントの局所stateとして持ちます。

// src/components/TodoInput.jsx
import { useState } from "react";

export default function TodoInput({ onAdd }) {
  const [inputValue, setInputValue] = useState("");

  const handleSubmit = (event) => {
    event.preventDefault();

    const normalizedValue = inputValue.trim();

    if (!normalizedValue) {
      return;
    }

    onAdd(normalizedValue);
    setInputValue("");
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <label htmlFor="todo-text">新しいTodo</label>
      <div className="todo-input-row">
        <input
          id="todo-text"
          name="todo"
          type="text"
          value={inputValue}
          onChange={(event) => setInputValue(event.target.value)}
          placeholder="やることを入力"
          autoComplete="off"
        />
        <button type="submit">追加</button>
      </div>
    </form>
  );
}

valueをstateへ結び付け、onChangeでstateを更新する入力欄は、制御された入力欄です。画面に表示される値の正本がReactのstateになるため、送信後のクリアや入力検証を同じデータフローで扱えます。

handleSubmit内では、追加処理へ現在の文字列を渡してから入力stateを空にします。state更新は即座に変数を書き換える処理ではありませんが、処理の意図が明確になるよう、利用する値を先に確定しています。

TodoList:props表示と親への操作通知

TodoListは一覧を表示しますが、Todoのstate自体は持ちません。

// src/components/TodoList.jsx
export default function TodoList({
  todos,
  onToggle,
  onDelete,
}) {
  if (todos.length === 0) {
    return <p className="todo-empty">Todoはありません。</p>;
  }

  return (
    <ul className="todo-list">
      {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
            type="button"
            onClick={() => onDelete(todo.id)}
            aria-label={`${todo.text}を削除`}
          >
            削除
          </button>
        </li>
      ))}
    </ul>
  );
}

onChange={() => onToggle(todo.id)}のように関数で包むことで、イベント発生時に対象TodoのIDを渡します。onChange={onToggle}とすると、Reactのイベントオブジェクトが第1引数として渡され、期待しているIDとは一致しません。

各項目のkeyには、一覧内で安定して識別できるIDを使います。配列の位置を示すindexは、並び替えや削除によって別の項目を指すことがあるため、この用途では避けます。

TodoStats:必要な情報に絞ったprops

TodoStatsは個々のTodoを知る必要がありません。表示する件数と、一括削除用のコールバックだけを受け取ります。

// src/components/TodoStats.jsx
export default function TodoStats({
  total,
  active,
  completed,
  onClearCompleted,
}) {
  return (
    <section className="todo-stats" aria-label="Todoの集計">
      <p>
        全{total}件 / 未完了{active}件 / 完了{completed}件
      </p>

      <button
        type="button"
        onClick={onClearCompleted}
        disabled={completed === 0}
      >
        完了済みを削除
      </button>
    </section>
  );
}

子コンポーネントへ渡す情報を用途に合わせて絞ると、そのコンポーネントがどのデータへ依存しているか分かりやすくなります。

複数の子コンポーネントで同じstateを共有する必要がある場合は、最も近い共通の親へstateを移し、propsで渡します。これがstateのリフトアップです。今回のtodosAppが所有する構成も、この考え方に沿っています。

useEffectの依存配列

useEffectは、コンポーネントを外部システムと同期するために使います。依存配列は「何回実行したいか」を任意に指定するスイッチではなく、Effect内で参照するリアクティブな値に合わせて決まります。

代表的な形は次の三つです。

// 初回コミット後と、todosが変わったコミット後に実行
useEffect(() => {
  localStorage.setItem(storageKey, JSON.stringify(todos));
}, [todos]);

// コンポーネント内のリアクティブな値を参照しない
useEffect(() => {
  const handleOnline = () => {
    console.log("online");
  };

  window.addEventListener("online", handleOnline);

  return () => {
    window.removeEventListener("online", handleOnline);
  };
}, []);

// 依存配列を省略すると、各コミット後に実行
useEffect(() => {
  console.log("rendered");
});
記述基本的な挙動
[todos]初回コミット後と、todosが変化したコミット後に実行
[]コンポーネント内のリアクティブな依存値がないEffect
依存配列なし各コミット後に実行

クラスコンポーネントのcomponentDidMountcomponentDidUpdateへ一対一で置き換えて覚えるより、外部システムとの一つの同期処理として考える方が、セットアップとクリーンアップを設計しやすくなります。

Effect再実行前のクリーンアップ

タイマーを設定するコンポーネントでは、対応するクリーンアップを返します。

import { useEffect } from "react";

export default function ClockLogger() {
  useEffect(() => {
    const intervalId = window.setInterval(() => {
      console.log(new Date().toISOString());
    }, 1_000);

    return () => {
      window.clearInterval(intervalId);
    };
  }, []);

  return <p>時刻をコンソールへ出力しています。</p>;
}

クリーンアップは、コンポーネントがDOMから取り除かれるときだけではありません。依存値が変わってEffectを再実行する前にも、古い値で作られた処理を停止するために呼ばれます。

Strict Modeが有効な開発環境では、最初の本番相当のセットアップ前に、セットアップとクリーンアップが追加で一度実行されます。タイマー、イベント購読、ネットワーク接続などは、何度セットアップと解除が行われても正しく動くように対にします。

CSS変数によるテーマ切り替え

テーマの色だけを切り替えるなら、Reactのstateへ持ち込まずCSSで処理できます。

/* src/index.css */
:root {
  color-scheme: light dark;
  --text: #4b5563;
  --heading: #111827;
  --background: #ffffff;
  --surface: #f8fafc;
  --border: #dbe3ec;
  --accent: #2563eb;
}

@media (prefers-color-scheme: dark) {
  :root {
    --text: #cbd5e1;
    --heading: #f8fafc;
    --background: #0f172a;
    --surface: #1e293b;
    --border: #334155;
    --accent: #60a5fa;
  }
}

body {
  margin: 0;
  color: var(--text);
  background: var(--background);
  font-family: system-ui, sans-serif;
}

button,
input {
  font: inherit;
}
/* src/App.css */
.todo-app {
  width: min(42rem, calc(100% - 2rem));
  margin: 4rem auto;
  padding: 1.5rem;
  border: 1px solid var(--border);
  border-radius: 1rem;
  background: var(--surface);
}

.todo-app h1 {
  color: var(--heading);
}

.todo-input-row,
.todo-stats,
.todo-list li,
.todo-list label {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.todo-input-row input {
  min-width: 0;
  flex: 1;
}

.todo-list {
  display: grid;
  gap: 0.75rem;
  padding: 0;
  list-style: none;
}

.todo-list li,
.todo-stats {
  justify-content: space-between;
}

.todo-list li.completed span {
  opacity: 0.65;
  text-decoration: line-through;
}

button {
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  padding: 0.5rem 0.75rem;
  color: var(--heading);
  background: transparent;
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

prefers-color-schemeはOSやブラウザの配色設定を参照します。手動のテーマ切り替えが必要になった場合は、利用者が選んだ値を属性やクラスとしてルート要素へ付与し、CSS変数を上書きする構成へ拡張できます。

小さなTodoアプリから確認できる設計原則

この実装で扱った内容を整理すると、次のようになります。

  1. Todo一覧の正本はAppに一つだけ置く
  2. 入力途中の文字列はTodoInputの局所stateに置く
  3. state内の配列とオブジェクトは直接変更しない
  4. 現在のstateから次のstateを作るときは関数形式の更新を使う
  5. 件数のような派生値はstateへ重複保存しない
  6. 子コンポーネントはpropsで値を受け取り、コールバックで操作を要求する
  7. useEffectはブラウザAPIなど外部システムとの同期に使う
  8. セットアップしたタイマーや購読には、対応するクリーンアップを用意する

Todoアプリが実務の状態管理をすべて表現できるわけではありません。データ取得、ルーティング、認証、複数画面での共有、サーバーとの競合が加われば、状態の置き場所や責務はさらに増えます。

それでも、基本となる考え方は変わりません。stateを必要な場所へ置き、重複を避け、更新の入口を明確にし、画面をそのstateから導出します。小さなアプリでこの流れを追えるようになると、規模が大きくなったときにも、どこでデータが変わるのかを判断しやすくなります。