第13章:リアルタイム購読②(Reactで安全に扱う)⚛️🧯
この章はひとことで言うと、**「onSnapshotを“安全に”Reactへ組み込む」**回です✨ リアルタイム購読って気持ちいいんだけど、やり方をミスると 二重購読 や メモリリーク になりがち…😇 なので、ここで「型・hooks・後片付け」まで“型崩れなく”固めます💪
1) 読む:なぜ“安全”が必要?🧨→🧯
✅ Firestoreのリアルタイム購読は「解除しないと残る」
Firestoreは onSnapshot() で変更を監視できます。最初に即スナップショットが届いて、その後も変更のたびに届きます⚡👀 (Firebase)
そして大事なのが、onSnapshot() が 購読解除用の関数(unsubscribe)を返すこと。これを呼ぶと監視が止まります🧯 (modularfirebase.web.app)
✅ Reactは「画面が消える」「条件が変わる」たびに後片付けが必要
Reactの useEffect() は、依存が変わる前や、コンポーネントが消えるときに cleanup(後片付け)を呼べる仕組みです🧹 (react.dev)
しかも開発中(Strict Mode)だと、バグ発見のために setup→cleanup→setup を1回余分に回すことがあります😳
つまり cleanup が弱いと、二重購読が起きやすいです(逆に、cleanupが正しければ安全)🛡️ (react.dev)
2) 手を動かす:useTodos() を作って購読を“hooks化”しよう 🛠️✨
ここからのゴールはこれ👇
TodosPage.tsxみたいな画面からはuseTodos()を呼ぶだけにする😆onSnapshot()の 解除漏れをゼロにする🧯- loading / error / empty を“見た目として”ちゃんと出す✨
2-1. まず型を作る(ToDoの形を固定)🧱
// src/types/todo.ts
import type { Timestamp } from "firebase/firestore";
export type Todo = {
id: string;
title: string;
done: boolean;
createdAt?: Timestamp;
updatedAt?: Timestamp;
tags?: string[];
};
Timestampは Firestoreの時刻型です⏱️(第11章の流れでOK👍)

2-2. useTodos()(購読+解除+状態管理)を作る⚡🧯
ポイントは3つだけ👇
onSnapshot()の戻り値(unsubscribe)を 必ず return cleanup で呼ぶ- 画面側は
statusを見て表示を分岐 - クエリ(
query(...))はuseMemoで安定させる(余計な再購読を減らす)🎯
// src/hooks/useTodos.ts
import { useEffect, useMemo, useState } from "react";
import {
collection,
onSnapshot,
orderBy,
query,
where,
type FirestoreError,
} from "firebase/firestore";
import { db } from "../lib/firebase"; // 既存のFirestore初期化を利用
import type { Todo } from "../types/todo";
type TodosState =
| { status: "loading"; todos: Todo[]; error: null }
| { status: "error"; todos: Todo[]; error: FirestoreError }
| { status: "ready"; todos: Todo[]; error: null };
export function useTodos(options?: { onlyUndone?: boolean }) {
const onlyUndone = options?.onlyUndone ?? false;
const [state, setState] = useState<TodosState>({
status: "loading",
todos: [],
error: null,
});
// ✅ クエリはuseMemoで“同じもの”を保つ(不要な再購読を減らす)
const q = useMemo(() => {
const base = collection(db, "todos");
return onlyUndone
? query(base, where("done", "==", false), orderBy("createdAt", "desc"))
: query(base, orderBy("createdAt", "desc"));
}, [onlyUndone]);
useEffect(() => {
setState({ status: "loading", todos: [], error: null });
// ✅ onSnapshotは「解除関数」を返す
const unsub = onSnapshot(
q,
(snap) => {
const todos: Todo[] = snap.docs.map((d) => {
const data = d.data() as Omit<Todo, "id">;
return { id: d.id, ...data };
});
setState({ status: "ready", todos, error: null });
},
(err) => {
setState({ status: "error", todos: [], error: err });
}
);
// ✅ これが命!!!! 画面が消える/条件が変わる→購読解除🧯
return () => unsub();
}, [q]);
const isEmpty = state.status === "ready" && state.todos.length === 0;
return { ...state, isEmpty };
}
onSnapshot() の基本挙動(最初に即通知→変更で通知、解除関数あり)はこちらの公式説明が土台です📚 (Firebase)
useEffect() の cleanup と Strict Mode の追加サイクルはここが根拠です🧠 (react.dev)

3) 手を動かす:画面で “loading / error / empty” を綺麗に出す✨🎛️
// src/pages/TodosPage.tsx
import { useState } from "react";
import { useTodos } from "../hooks/useTodos";
export function TodosPage() {
const [onlyUndone, setOnlyUndone] = useState(false);
const { status, todos, error, isEmpty } = useTodos({ onlyUndone });
return (
<div style={{ padding: 16 }}>
<h1>ToDo 🗃️</h1>
<button onClick={() => setOnlyUndone((v) => !v)}>
{onlyUndone ? "全部表示に戻す" : "未完了だけ表示"}
</button>
{status === "loading" && <p>読み込み中…⏳</p>}
{status === "error" && <p>エラー😭:{error.message}</p>}
{isEmpty && <p>まだ1件もないよ📝(追加してみて!)</p>}
<p>件数:{todos.length} 件 🔢</p>
<ul>
{todos.map((t) => (
<li key={t.id}>
<input type="checkbox" checked={t.done} readOnly /> {t.title}
</li>
))}
</ul>
</div>
);
}
これで、別タブから追加すると 勝手に増える(第12章の快感)を保ちつつ、React的にも安全になります⚡🧯
4) よくある事故パターン集(ここ踏む人多い)💥😇
💥 事故1:cleanupを書かずに購読が残る
- 画面遷移しても購読が生きてて、更新のたびに state 更新が飛ぶ…
- 最終的に「なんか重い」「二重に増える」になる🫠
→
return () => unsub()が正解🧯 (react.dev)

💥 事故2:Strict Modeで「二重購読してるように見える」
開発中は わざと setup→cleanup→setup を1回余分に回します🧪 cleanupが正しければ「問題なし」👍(本番は通常どおり) (react.dev)

💥 事故3:依存配列が毎回変わって再購読ループ
query(...) を毎レンダーで作ると、Effectが「別物だ!」って判断して再購読しがち😵
→ useMemo() で安定させるのが楽です🎯
5) ミニ課題 🧩🎯
次の3つ、やってみて!✨
-
フィルタ切替を入れる
- 「未完了だけ」⇄「全部」の切替
- 切り替えた瞬間に一覧が自然に変わる🎛️
-
状態表示を強化
- loading:スケルトン風でもOK😆
- error:
error.messageを表示 - empty:かわいいメッセージ📝
-
安全確認
- 画面を行ったり来たりしても、増殖しない
- 追加したら1回だけ反映される(2回増えない)✅
6) チェック(合格ライン)✅✨
-
onSnapshot()の戻り値(unsubscribe)を cleanupで呼んでいる 🧯 (modularfirebase.web.app) - Strict Modeでも「二重購読っぽい挙動」を cleanupで潰せている 🧪🛡️ (react.dev)
-
useTodos()の戻り値だけで画面が書ける(UIがスッキリ)✨ - loading / error / empty が出せてる ⏳😭📝
7) AIでさらに加速(オプション)🤖🚀
7-1) Gemini CLI / Antigravityで“unsubscribe漏れレビュー”してもらう🕵️♂️✨
Gemini CLI はターミナルで使えるAI支援、Antigravityはエージェント駆動の開発環境(Mission Control)って位置づけです🧠⚙️ (Google Cloud Documentation)
たとえばこんなお願いが強いです👇
- 「この
useEffect、Strict Modeでも二重購読しない?どこが危ない?」 - 「依存配列、最小でOK?
useMemoの置きどころは?」 - 「状態設計(loading/error/empty)もっと読みやすくできる?」
“人間が見落としやすいポイント”を先に潰せるのがうまいです🧯✨

7-2) Firebase AI Logicで「AIがToDo案を出す」→リアルタイム反映を体験🪄🗃️
Firebase AI Logic は WebアプリからGemini/Imagenを安全寄りに呼べる仕組みです🤖🔐 (Firebase) Webの初期化はこんな感じ(公式の形)👇 (Firebase)
import { initializeApp } from "firebase/app";
import { getAI, getGenerativeModel, GoogleAIBackend } from "firebase/ai";
const app = initializeApp({ /* ... */ });
const ai = getAI(app, { backend: new GoogleAIBackend() });
const model = getGenerativeModel(ai, { model: "gemini-2.5-flash" });
export async function generateTodoTitle(): Promise<string> {
const prompt = "日本語で短いToDoタイトルを1つだけ提案して。15文字以内。";
const result = await model.generateContent(prompt);
return result.response.text().trim();
}
ちょい注意⚠️:モデル名は運用で変わることがあるので、古いモデルを固定してる場合は退役情報も確認してね(例:一部モデルは 2026-03-31 に退役予定の案内あり)📅 (Firebase)
あとは generateTodoTitle() の結果を addDoc() で todos に入れるだけ!
するとこの章で作った useTodos() が リアルタイムで勝手に増やしてくれます⚡😆(「購読の快感」と「安全設計」が同時に味わえる🍰)
次の第14章(whereフィルタ)に行く前に、この第13章のhooks化ができてると、以降ぜんぶ楽になります💪🔥