Firebaseを使ったアプリ開発で、多くの初心者が最初につまずくのが「リアルタイムなデータ操作」の実装です。
本記事では、GoogleのNoSQLデータベース「Cloud Firestore」からデータを取得し、React/Next.jsの画面に一覧表示する方法を解説しています。
データの取得に欠かせないuseEffectの使い方はもちろん、Firestoreの最大の強みであるリアルタイム更新を実現するonSnapshotについて、具体的なコード例とともに解説しています。
単なる読み取りだけでなく、データの「編集」や「削除」といった基本操作(CRUD)まで網羅しているため、この記事を読み終える頃には、動きのある動的なアプリケーションの土台が完成しているはずです。
Firestoreを使った簡単なアプリの作成
本記事は、下記のFirestoreを使ったデータ保存の基本的なアプリケーションの拡張版です。
> Firestoreの使い方を実例で解説|データを保存する方法・コレクションとドキュメント、フィールドとは何か?(Next.js) addDoc collectionの使い方
ユーザー一覧ページの作成
まずはFirestoreから取得したユーザー情報を表示するためのページを作成します。
appディレクトリ配下にusersディレクトリを作成し、page.tsxを作成します。

App Routerでは、app/users/page.js というフォルダとファイルを作るだけで、自動的に localhost:3000/users というページが出来上がります。App Routerについては下記をご参考ください。
トップページからリンクをつなぐ
トップページからユーザー一覧ページに遷移するためのリンクを設置します。
Next.jsではアプリ内でページ遷移する際はLinkタグを使います。
appディレクトリ直下のpage.tsxの冒頭に以下を追加します。
import Link from "next/link";Next.jsのlinkモジュールをLinkとして読み込みます。
続いて、returnのボタンタグの下に以下のコードを追記します。
<div className="mt-8 text-center">
<Link href="/users" className="text-blue-600 hover:underline">
→ ユーザー一覧・管理ページへ
</Link>
</div>
トップページは以下のようになります。

Next.jsではLinkタグを非常によく使います。Linkタグの詳細は下記をご参考ください。
> 【Next.js】なぜaタグではなくLinkタグを使うのか?メリットやいつ使うか?(import Link from “next/link”;)
ユーザー一覧ページのコード
作成したusersディレクトリのpage.tsxに以下のコードを記述します。
"use client";
import { useState, useEffect } from "react";
import { db } from "@/lib/firebase";
import {
collection,
query,
orderBy,
onSnapshot,
doc,
deleteDoc,
updateDoc
} from "firebase/firestore";
import Link from "next/link";
export default function UsersPage() {
const [users, setUsers] = useState([]);
const [editingId, setEditingId] = useState(null);
const [editData, setEditData] = useState({ name: "", email: "" });
// 1. データのリアルタイム取得 (Read)
useEffect(() => {
const q = query(collection(db, "users"), orderBy("createdAt", "desc"));
// onSnapshotを使うと、Firebase側が更新された瞬間に画面も変わります
const unsubscribe = onSnapshot(q, (querySnapshot) => {
const usersArray = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
setUsers(usersArray);
});
return () => unsubscribe(); // ページを離れたら接続を解除
}, []);
// 2. データの削除 (Delete)
const handleDelete = async (id) => {
if (confirm("本当に削除しますか?")) {
await deleteDoc(doc(db, "users", id));
}
};
// 3. 編集モードの開始
const startEdit = (user) => {
setEditingId(user.id);
setEditData({ name: user.name, email: user.email });
};
// 4. データの更新 (Update)
const handleUpdate = async (id) => {
await updateDoc(doc(db, "users", id), {
name: editData.name,
email: editData.email
});
setEditingId(null);
};
return (
<main className="min-h-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8 text-gray-800">ユーザー一覧</h1>
<div className="grid gap-4">
{users.map((user) => (
<div key={user.id} className="bg-white p-6 rounded-xl shadow-md flex justify-between items-center text-black">
{editingId === user.id ? (
// 編集中の表示
<div className="flex gap-2 flex-grow mr-4">
<input
className="border p-2 rounded w-full"
value={editData.name}
onChange={(e) => setEditData({...editData, name: e.target.value})}
/>
<input
className="border p-2 rounded w-full"
value={editData.email}
onChange={(e) => setEditData({...editData, email: e.target.value})}
/>
</div>
) : (
// 通常時の表示
<div>
<p className="font-bold text-lg">{user.name}</p>
<p className="text-gray-500">{user.email}</p>
</div>
)}
<div className="flex gap-2">
{editingId === user.id ? (
<button onClick={() => handleUpdate(user.id)} className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">保存</button>
) : (
<button onClick={() => startEdit(user)} className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">編集</button>
)}
<button onClick={() => handleDelete(user.id)} className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600">削除</button>
</div>
</div>
))}
</div>
{users.length === 0 && <p className="text-center text-gray-500">ユーザーがいません。</p>}
</div>
<div className="mt-8 text-center">
<Link href="/" className="text-blue-600 hover:underline">
→ トップページへ
</Link>
</div>
</main>
);
}/usersページは以下のようになります。

データ変更で起こる処理の流れ
データの編集・削除をした際に発生する処理は以下の流れになりますj。
- updateDoc / deleteDoc を実行
- Firestore のデータが変わる
- Firestore が変更を検知
- onSnapshot の callback が自動実行
- querySnapshot が新しくなる
- setUsers で state 更新
- 画面更新
上記の処理が発生する理由も含め、下記で解説します。
コード解説
useEffect(※超重要|無限ループによる課金を防ぐ)
冒頭でuseStateとuseEffectを読み込んでいます。
import { useState, useEffect } from "react";useStateはトップページのデータ保存画面と同じく、入力値の状態を維持するものです。
useEffectは、初回画面ロード時のみFirestoreと接続してデータ取得するために必要なモジュールです。
useStateを使っているため、画面上の入力値が変わる度にこのコンポーネントはリロードされます。もし、useEffectの記述が無ければ、その度にFirebaseと何度も通信することになります。
このため、以下のコードをuseEffectの外に書くと、再描画の度に onSanpshotが実行され、Firebaseとの通信が発生します。
const q = query(collection(db, "users"), orderBy("createdAt", "desc"));
// onSnapshotを使うと、Firebase側が更新された瞬間に画面も変わります
const unsubscribe = onSnapshot(q, (querySnapshot) => {
const usersArray = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
setUsers(usersArray);
});useEffectは、画面表示後に実行する処理と、どの値が変わった場合にその処理を再度実行するかを指定することができます。
useEffect(() => {
ページロード後に実行する処理
return クリーンアップ関数
}, [監視対象]);このとき、監視対象を空にすることで、初回マウント時のみ処理を実行する指示になります。
useEffect(() => {
ページロード後に実行する処理
return クリーンアップ関数
}, [監視対象]);今回のコードもこのコードの形になっています。
なお、クリーンアップ関数はページを離れたときに実行する関数です。
今回のuseEffectでは監視対象が空なので、画面上で入力や変更があった場合にNext.jsからFiresotreからデータを取得しに行くことはしません。
データが変更されると、Firestoreのデータが置き換わりその結果を反映する仕組みになっています(onSnapshot)。
このFirestore⇒Next.jsの通信を切断するのがクリーンアップ関数です。
firestoreに投げるクエリ
firestoreに投げるクエリは以下の形になります。
query(コレクション, 条件1, 条件2, ...)最初にどのDBのどのコレクションかを指定します。その後に抽出条件を指定します。
const q = query(collection(db, "users"), orderBy("createdAt", "desc"));↓↑ 同じ
const q = query(
collection(db, "users"), // <コレクション>
orderBy("createdAt", "desc") // <条件1>
);
今回の場合、usersコレクションのデータに対し、登録日(createdAt)の降順(desc)で取得する指示になっています。
なお、Firestoreに投げるためのquery, collection, orderByは事前にfirestoreのモジュールからimportしておく必要があります。
import {
collection,
query,
orderBy,
} from "firebase/firestore";
onSnapshotでFirestoreのデータをリアルタイム監視する
onSnapshotとは、Firestoreのデータをリアルタイムで監視するための関数です。
onSnapshot(クエリ, コールバック)この関数を実行すると2つのことを行います。
- Firestoreとの接続を開始し、queryの結果を監視する
- 変更があるとcallback関数を実行する
- 監視を止めるための関数を返す
function onSnapshot(query, callback) {
// ①Firestoreと接続して監視を開始
const connectionId = startListening(query, callback);
// ②「この監視を止めるための関数」を返す
return function unsubscribe() {
stopListening(connectionId);
};
}このため、定数 unsubscribeには、戻り値のunsubscribe関数が入ります。
const unsubscribe = onSnapshot(q, (querySnapshot) => {
const usersArray = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
setUsers(usersArray);
});監視対象の「querySnapshot」はqの実行結果を丸ごと持ったオブジェクトです。この中のデータが一つでも変わればFirestoreから通知が来て中の処理を実行します。
onSnapshot(q, (querySnapshot) => {
const usersArray = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
setUsers(usersArray);
});usersArrayの実行内容
qのクエリで取得した生データ(querySnapshot)をReactで扱いやすい形に整形しています。
Firebaseから返ってくるquerySnapshotは、単なる配列ではなく、Firebase専用の複雑なオブジェクトです。
docsプロパティでその中に入っているドキュメント(1件ずつのデータ)の集まりを取得しています。
idとdoc.data()を分ける理由
Firestoreのドキュメントは、「ID(名前)」と「中身(フィールド)」が別々に管理されています。
このため、doc.data() を実行しても、そのドキュメントのID(例:abc12345)は中身に含まれていません。
編集や削除をするときにはこの「ID」が絶対に必要になるため、ここで明示的に id という名前で取り出しています。
この処理により、usersArrayは最終的に以下の形に整形されます。
{
id: "user_01", // doc.id から取得
name: "太郎", // ...doc.data() によって展開された中身
email: "taro@ex.com" // ...doc.data() によって展開された中身
}deleteDoc
deleteDoc は、Firestoreのドキュメントを削除するためのAPIです。
以下のように指定します。
await deleteDoc(doc(db, "コレクション名", 対象のID));deleteDoc は Promise を返す非同期関数なので、awaitをつけて処理結果を待つ必要があります。
エラーがあった場合のみエラーが返ります。
deleteDocを使うには、ファイル冒頭でfirestoreモジュールからimportします。
import { doc, deleteDoc } from "firebase/firestore";updateDoc
updateDoc は、Firestoreのドキュメントを編集するためのAPIです。
以下のように指定します。
await updateDoc(doc(db, "コレクション名", 対象のID));updateDoc は Promise を返す非同期関数なので、awaitをつけて処理結果を待つ必要があります。
エラーがあった場合のみエラーが返ります。
updateDocを使うには、ファイル冒頭でfirestoreモジュールからimportします。
import { doc, updateDoc } from "firebase/firestore";
