第13章:永続化①:まずはLocalStorageで保存する💾✨
この章の「いまどき」前提メモだけ先に置くね📝✨ TypeScript は npm だと 5.9.3 が最新(2025/09公開)で、Vite は 7.3.1 が最新(2026/01公開)になってるよ〜🎯 (NPM)
0. この章のゴール🎯✨
- Todoを追加しても、ページを更新しても消えないようにする🔄✅
- MVCの責務を崩さずに、保存=外部として扱えるようになる🧠🧱
localStorageのクセ(文字列だけ・容量・同期処理)に慣れる💪📦
1. 永続化って、なんで「外部」なの?🪐

保存(localStorage / サーバー / DB)って、アプリの外側の世界だよね🌍
だから Modelの純度を保つために、Modelの中に localStorage.setItem(...) とかを書かないのがコツ🙅♀️💦
- Model:ルールと状態(Todoの正しさを守る)🛡️
- View:表示だけ🎨
- Controller:入力を受けて指示🚦
- Service:処理のまとめ役🥗
- 永続化:アプリの外(今回は localStorage)💾✨
localStorage は「同一オリジンの key/value 保存」で、ブラウザを閉じても残るよ〜📌 (MDN Web Docs)
2. localStorageの基礎だけ、最短で押さえよう⚡
2-1. 文字列しか入らない📎
localStorage は key/value どっちも文字列。
だから JSONにして保存 → JSONをパースして復元 が基本だよ📄✨ (MDN Web Docs)
2-2. 同期処理(=重いと固まることある)🐢

setItem/getItem/removeItem は 同期なので、量が多いとUIがモタつく原因になりやすいの⚠️ (MDN Web Docs)
→ 今回はTodo数が少ない想定だからOKだけど、「大量データ」はIndexedDBへ…って流れが王道だよ🧠✨
2-3. 容量制限がある📦
ブラウザは localStorage に 1オリジンあたりだいたい 5MiB くらいまで、上限超えると QuotaExceededError を投げる想定で try/catch が安心💡 (MDN Web Docs)
3. 今回の設計方針(第14章の伏線つき🧲✨)
第14章で Repository に差し替え可能にするんだけど、今章はまず **「保存と復元が動く」**を最優先にするよ✅
ここでは「雑に混ぜない」ために、最低限こう分けるね👇
TodoLocalStorageStore:localStorageとのやりとり専門💾TodoService:操作(追加・トグル)後に保存を呼ぶ🥗Controller:起動時にロードして Model に流し込む🚦➡️📦
4. 実装していこう〜!🛠️✨(手順どおりでOK)
4-1. 永続化用の型(DTO)を作る📦🧷
ポイント:Modelの型(Date とか)をそのまま保存しないで、保存用に整えるよ🧁
Dateは文字列(ISO)にして保存→復元時に Date に戻すのが王道✨
// src/infra/todoPersistenceTypes.ts
export type PersistedTodoItem = {
id: string;
title: string;
done: boolean;
dueDateIso: string | null; // DateはISO文字列にする
};
export type PersistedTodoStateV1 = {
version: 1;
items: PersistedTodoItem[];
};
4-2. localStorage アダプターを作る💾🧱
- 保存キーはアプリ名+バージョンで固定(あとで移行しやすい)🗝️✨
JSON.parseは壊れたデータで落ちるので、必ずtry/catch🧯
// src/infra/todoLocalStorageStore.ts
import type { PersistedTodoItem, PersistedTodoStateV1 } from "./todoPersistenceTypes";
import type { TodoItem } from "../model/todoItem";
const STORAGE_KEY = "campusTodo:v1";
export class TodoLocalStorageStore {
load(): TodoItem[] {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as PersistedTodoStateV1;
// ちょい安全チェック(最低限)
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.items)) return [];
return parsed.items.map((p): TodoItem => ({
id: p.id,
title: p.title,
done: p.done,
dueDate: p.dueDateIso ? new Date(p.dueDateIso) : null,
}));
} catch {
// 壊れてたら潔く空に(学習用としてはこれでOK)
return [];
}
}
save(items: TodoItem[]): void {
const payload: PersistedTodoStateV1 = {
version: 1,
items: items.map(
(i): PersistedTodoItem => ({
id: i.id,
title: i.title,
done: i.done,
dueDateIso: i.dueDate ? i.dueDate.toISOString() : null,
}),
),
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch (e) {
// QuotaExceededError 等も想定(本番なら通知UIへ)
console.warn("Failed to save to localStorage", e);
}
}
clear(): void {
localStorage.removeItem(STORAGE_KEY);
}
}
4-3. Controller(または起動処理)で「復元」する🔁✨
アプリ起動時に
- store.load()
- Modelに流し込む
- Viewを描画する
この順番が気持ちいいよ〜🌀
例として Model に replaceAll(items) がある想定で書くね(なければ追加してOK)🧡
// src/controller/todoController.ts(例:一部だけ)
import { TodoLocalStorageStore } from "../infra/todoLocalStorageStore";
import { TodoService } from "../service/todoService";
import { TodoListModel } from "../model/todoListModel";
import { TodoView } from "../view/todoView";
export class TodoController {
constructor(
private readonly model: TodoListModel,
private readonly view: TodoView,
private readonly service: TodoService,
private readonly store: TodoLocalStorageStore,
) {}
init(): void {
const restored = this.store.load();
this.model.replaceAll(restored);
this.view.render(this.model.getSnapshot());
// ここでイベント購読などをセットアップ…(既にある想定)🎮
this.wireEvents();
}
private wireEvents(): void {
// 例:view.onAdd((title, due) => ...)
}
}
4-4. Serviceで「操作のあと保存」する✅💾
「追加したら保存」「トグルしたら保存」みたいに、状態が変わる操作の最後で store.save(...) を呼ぶのが超わかりやすいよ🍀
// src/service/todoService.ts(例)
import type { TodoItem } from "../model/todoItem";
import { TodoListModel } from "../model/todoListModel";
import { TodoLocalStorageStore } from "../infra/todoLocalStorageStore";
export class TodoService {
constructor(
private readonly model: TodoListModel,
private readonly store: TodoLocalStorageStore,
) {}
addTodo(title: string, dueDate: Date | null): void {
this.model.addTodo(title, dueDate);
// 状態が変わったら保存💾
this.store.save(this.model.getAll());
}
toggleDone(id: string): void {
this.model.toggleDone(id);
this.store.save(this.model.getAll());
}
}
5. 動作チェック✅✨(ここまでできたら勝ち🎉)
チェック項目はこれだけ👇
- Todoを追加する ➕✅
- ページ更新(F5)🔄
- 追加したTodoが残ってる🎉💾
- 完了チェックを切り替える✅🔁
- 更新しても状態が残る✨
6. よくある落とし穴🕳️(先に回避しよっ😇)
6-1. Dateが文字列になって戻ってくる問題📅
JSONはDateを保存できないから、ISO文字列で保存→復元時 new Date(...) が正解🙆♀️✨
6-2. localStorageの処理は同期(重いと固まる)🐢
大量データを毎回 JSON.stringify するとカクつきやすい⚠️ (MDN Web Docs)
→ 今は小規模なのでOK。増えたら「保存頻度を減らす」「IndexedDB」へ💡
6-3. 容量上限で保存に失敗する📦
上限に達すると例外が出る想定(QuotaExceededError)だから try/catch が安心🧯 (MDN Web Docs)
7. 発展:別タブで開いたとき同期したい人向け🔁🪟
storage イベントで「別タブの変更」を受け取れるよ✨
※変更した同じタブには飛んでこない仕様! (MDN Web Docs)
window.addEventListener("storage", (e) => {
if (e.key !== "campusTodo:v1") return;
// ここで store.load() して model.replaceAll → view.render すれば同期できる✨
});
8. AI相棒(Copilot / Codex)への頼み方🤖💡(そのままコピってOK)
保存の型づくりを手伝ってもらう🧁
- 「TodoItem(id/title/done/dueDate)を localStorage に保存するための DTO 型と変換関数を TypeScript で作って。dueDate は ISO 文字列にして」
例外・壊れたデータへの耐性🧯
- 「JSON.parse が壊れてても落ちない load 実装にして。最低限の型チェックも入れて」
差分レビュー用🧐
- 「この変更で MVC の責務が混ざってる箇所がないかチェックして。混ざってたら理由つきで指摘して」
9. 次章へのつなぎ(第14章の予告)🔁🧲✨
今は TodoService → TodoLocalStorageStore に直結してるけど、次はここを
ITodoRepository(interface)LocalStorageTodoRepository(実装)
みたいにして、保存先を差し替え可能にするよ〜!🧱✨ 「永続化は外部」って考え方が、ここで一気に強くなるはず💪💖