ReactでTodoアプリを作る #5 永続化と締めくくり

読了 9分

前回はインライン編集まで終えました。ところがリロードするとデータがすべて消えます — すべてがメモリにだけあってどこにも保存されないからです。今回の記事ではlocalStorageで永続化してリロードしてもデータが維持されるようにし、シリーズ全体を振り返りながら締めくくります。

今回のステップの目標 #

  • タスク一覧がlocalStorageに自動保存される
  • ページロード時に保存されたデータを復元
  • フィルタ状態(activecompleted)も一緒に維持
  • シリーズ振り返り + 次のステップ案内

useLocalStorageカスタムフック #

基礎講座#13ですでに作ったことのあるフックです。そのまま持ってきて使いましょう。

src/hooks/useLocalStorage.js:

src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // 保存失敗は静かに無視 (容量超過など)
    }
  }, [key, value]);

  return [value, setValue];
}

#13で作ったものにtry/catchを追加しました。localStorageは次のような失敗の可能性があります。

  • JSON.parse失敗 — 別のコードが保存した不正なデータが入っているとき
  • localStorage.setItem失敗 — 保存容量超過(普通5MB)、シークレットモード一部環境など

このような場合にアプリ全体が壊れるよりも静かにフォールバックする方がユーザー体験がよいです。

注記
初期値の引数に関数を渡した点(useState(() => { ... }))も再度押さえておくと、これは#5でちらっと言及した「遅延初期化(lazy initializer)」パターンです。useState(localStorage.getItem(...))のように直接呼び出すと毎回のレンダリングごとにlocalStorageを読みますが、関数で包むと最初のレンダリング時に1回だけ実行されます。localStorageのようにコストのある作業の初期化に適しています。

TodoAppに適用 #

useStateuseLocalStorageに変えるだけです。インターフェースが同じなので他のコードは触らなくてよいです。

src/TodoApp.jsx変更部分:

src/TodoApp.jsx (修正部分)
import { useState } from 'react';
import { useLocalStorage } from './hooks/useLocalStorage';
// ... 他の import ...

function TodoApp() {
  const [todos, setTodos] = useLocalStorage('todos', []);
  const [filter, setFilter] = useLocalStorage('todoFilter', 'all');
  const [editingId, setEditingId] = useState(null);

  // ... 残りはそのまま ...
}

todosfilterは永続化対象で、editingIdは一時的なUI状態なのでそのままuseStateにしておきます — ページをリロードしたのに編集モードがそのまま残っているとかえって違和感があるので。

キー名('todos''todoFilter')はlocalStorageに保存される識別子です。他のアプリと衝突しないように十分明確な名前にする方がよいです。大きなアプリでは'myapp:todos'のようにprefixを付ける慣習もあります。

動作確認 #

保存して次を試してみてください。

  1. タスクをいくつか追加して一部を完了表示
  2. フィルタを「未完了」に変更
  3. ページリロード(Cmd+RまたはF5)
  4. すべてのタスクとフィルタ状態がそのまま復元
  5. ブラウザ開発者ツール → Applicationタブ → Local Storage → ドメイン → 'todos''todoFilter'キーがあるか確認

新しいタブで同じページを開いても(同じドメインなら)localStorageが共有されるのでデータがそのまま見えます。

空の状態で開始 vs デモデータ #

初めて訪問したユーザーに空の画面を見せると「どう使えばいいのか」途方に暮れる可能性があります。シード(seed)データを入れるか決めなければならないときがあります。オプションは:

A. 空の状態で開始 + 案内文(現在)

  • シンプルで明確
  • 「タスクがありません。新しく追加してみてください。」案内が空の状態の処理の役割

B. デモデータで開始

const [todos, setTodos] = useLocalStorage('todos', [
  { id: 'demo-1', text: 'Reactの勉強', completed: true },
  { id: 'demo-2', text: '運動30分', completed: false },
]);
  • ユーザーがすぐに機能を見回せる
  • 欠点: デモデータをいちいち消す必要がある

今回のシリーズはAにします。両方とも合理的なのでお好みに合わせて選んでください。

他のタブとの同期(オプション) #

同じドメインの2つのタブを開いておいて片方でタスクを追加すると、他方のタブはリロードするまで変化を知りません。localStorage自体は変更されたのにReactはそれを知らないからです。

ブラウザはlocalStorageが他のタブで変更されるとstorageイベントを発生させます。これを購読すると自動同期が可能です。

フック拡張 (オプション)
useEffect(() => {
  function handleStorage(e) {
    if (e.key === key && e.newValue !== null) {
      try {
        setValue(JSON.parse(e.newValue));
      } catch {
        // ignore
      }
    }
  }
  window.addEventListener('storage', handleStorage);
  return () => window.removeEventListener('storage', handleStorage);
}, [key]);

機能が必要な時点で追加しても十分です。今回のシリーズでは単純化のため省略しました。

その他の改善アイデア #

ここまで作ると自分でさらに発展させるネタがたくさん見えてくるはずです。

  • 見た目の調整: インラインスタイルをCSS Modules / Tailwind / styled-componentsに分離。モバイルレスポンシブ
  • ドラッグで順序変更: react-dnd@dnd-kit/coreを使用
  • カテゴリ/タグ: タスクにタグを付けてフィルタに追加
  • 締め切り日: 日付フィールドを追加、過ぎた項目を強調
  • 検索: 入力テキストで即時フィルタリング(#13のuseDebounceを活用するよい機会)
  • ダークモード: #12で作ったThemeContextを適用
  • TypeScriptマイグレーション: 項目オブジェクトの型を明示して安全性アップ
  • テスト: Vitest + React Testing Libraryで核心動作のユニットテスト
  • バックエンド連携: 本当に同期させるならサーバーが必要。JSON ServerやSupabaseのようなBaaSで実験可能

それぞれがよい学習ネタで、小さなアプリほど実験する負担がありません。作りたいものがあれば挑戦してみてください。

シリーズ振り返り #

このシリーズで私たちは次を作りました。

#追加された機能登場した核心パターン/ツール
1追加/削除、コンポーネント分解単方向データフロー、stateのリフトアップ、UUID、controlled form
2完了トグル、統計イミュータブル更新(mapパターン)、派生値
3フィルタリング、一括削除データ配列でオプションをレンダリング、空状態の分岐
4インライン編集useRef、draft state、キーボード処理、onBlur
5localStorage永続化カスタムフックの再利用、lazy initializer

順を追って付いてきていただいたなら、シンプルなコンポーネント1つから始まって小さな実戦アプリができ上がる過程を直接体験されたはずです。

基礎講座で学んだものたちがどう合わさったか #

このシリーズでほぼすべての基礎概念が自然に登場しました。

  • コンポーネントとprops (#4) — TodoForm、TodoItem、TodoList、TodoStats、TodoFilterで画面を責任単位で分解
  • useState (#5) — todos、filter、editingId、draftなどすべての変わるデータ
  • イベントハンドリング (#6) — onClick、onChange、onSubmit、onKeyDown、onBlur
  • 条件付きレンダリング (#7) — 空状態、編集モード分岐、一括削除ボタン
  • リストとkey (#8) — todosをmapで描いてUUIDをkeyに
  • フォームの扱い (#9) — controlled componentパターン、チェックボックス
  • useEffect (#10) — localStorage同期、編集モード進入時のフォーカス
  • stateのリフトアップ (#11) — todosはTodoAppに、子はコールバックで通知
  • useContext (#12) — 登場しなかった(この規模ではpropの伝達の方が明確)
  • カスタムフック (#13) — useLocalStorage
  • パフォーマンス最適化 (#14) — 意図的に最適化しなかった(現在の規模には不要)
  • ルーティング (#15) — 単一画面なので未使用(マルチページアプリなら登場)

すべての基礎概念が毎回登場するわけではありません。必要なツールをその状況に合わせて選んで使う感覚が次第に身につくのがより重要です。1つのツールですべてを解決しようとするより、「この状況にはこれの方が合う」と判断できるようになるのが本当の実力です。

次のステップのおすすめ #

このシリーズを終えられたなら次のうちの1つに進むのがよいです。

A. もう1つの小さなプロジェクトを作る(もっともおすすめ) #

自分で小さなアプリをもう1つ作ってみてください。自分の日常に役立つ小さなツールがもっともよいです。

  • 運動記録(昨日の回数と比較)
  • 習慣トラッカー(チェックボックスカレンダー)
  • メモ帳(markdownサポート、検索)
  • 家計簿(月別合計)
  • 読書記録(本 + メモ)

機能を欲張るより最初のバージョンを素早く完成 → 使ってみながら改善するサイクルが学習速度が速いです。

B. モダンReact 19 + Next.jsシリーズ(予定) #

このブログの次のシリーズで、Server Components / use() / Actions / Suspenseのような最新パターンを扱う予定です。クライアントサイドだけでは足りない領域をどう解いていくかを見ることになります。

C. もう少し大きな実戦ビルド(予定) #

ブログ、ショッピングモールのようにルーティング + 状態管理 + データフェッチがすべて入るより大きなプロジェクト。

おわりに #

ここまでついてきてくださってありがとうございます。シンプルな入力欄1つから始まって、コンポーネント分離 → トグル → フィルタリング → 編集 → 永続化まで小さな実戦アプリが完成しました。最初は途方に暮れて見えたものが順番に追加されていきながら手に馴染んだことでしょう。

Reactは道具にすぎません。私たちが作ったものが派手でなくても頭の中のアイデアを手で作り出す経験をしたことが何よりの収穫です。その感覚が積み重なればどんなライブラリ、どんなフレームワークでも素早く習得できるようになります。

以上でTodoアプリ構築シリーズを終了します。

X