Firebaseから得た入力が重複して表示されてしまう
Q&A
Closed
解決したいこと
ブックマーク一覧が一つだけ表示されるようにしたい。
発生している問題・エラー
Firebase Firestoreに保存されているブックマーク済みの投稿データを取得し、そのデータをまとめて表示するために書いたコードです。以下の画像参照していただければ分かる通り、ブックマークを追加する度に、同じ内容のブックマーク一覧が複数回表示されるようになってしまいました。
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044653/cf8316b3-05fe-4649-8029-a102f82c5753.png
ディレクトリ構造
app(ルート直下)
┣ music
┃ ┣ bookmarks
┃ ┃ ┗ page.js
┃ ┣ login
┃ ┃ ┗ page.js
┃ ┣ register
┃ ┃ ┗ page.js
┃ ┗ page.js
┣ favicon.ico
┣ globals.css
┣ layout.js
┣ page.js
┗ page.module.css
components(ルート直下)
┣ MusicCard.js
┗ PopularArtists.js
該当するソースコード①
【app/bookmarks/page.js】
"use client";
import { auth } from "@/firebase/firebase";
import { db } from "@/firebase/firebase";
import { collection, query, where, getDocs } from "firebase/firestore";
import { useState, useEffect } from "react";
import MusicCard from "@/components/MusicCard";
export default function BookmarksPage() {
const [bookmarks, setBookmarks] = useState([]);
const [userId, setUserId] = useState(null);
useEffect(() => {
// ユーザーの認証状態の変更を監視
const unsubscribe = auth.onAuthStateChanged((user) => {
if (user) {
console.log("ユーザーがログインしています", user.uid);
setUserId(user.uid); // ユーザーがログインしたらユーザーIDを保存
} else {
console.log("ユーザーがログアウトしています");
setUserId(null); // ログアウトしたらユーザーIDをnullに
}
});
// クリーンアップ
return () => unsubscribe();
}, []); // 初回のみ実行
useEffect(() => {
// ユーザーIDがセットされたときにブックマークを取得
if (userId) {
const fetchBookmarks = async () => {
try {
const bookmarksQuery = query(
collection(db, "bookmarks"),
where("userId", "==", userId)
);
const bookmarkSnapshot = await getDocs(bookmarksQuery);
const fetchedBookmarks = bookmarkSnapshot.docs.map(doc => ({
...doc.data(),
id: doc.id
}));
console.log("取得したブックマーク:", fetchedBookmarks);
setBookmarks(fetchedBookmarks); // 取得したブックマークをステートにセット
} catch (error) {
console.error("ブックマークの取得に失敗しました:", error);
}
};
fetchBookmarks();
}
}, [userId]); // userIdが変わるたびに実行
return (
<div
className=" flex flex-col items-center justify-center min-h-screen bg-cover bg-center"
style={{ backgroundImage: 'url("/images/white_00115.jpg")' }} // 背景画像を設定
>
{bookmarks.length > 0 ? (
bookmarks.map((bookmark) => (
<MusicCard key={bookmark.id} music={bookmark} />
))
) : (
<p className="text-4xl font-bold items-center justify-center mb-35 text-black">
ブックマークされた投稿が表示されるまでしばらくお待ちください……
</p>
)}
</div>
);
}
該当するソースコード②
【components/MusicCard.js】
import { useState, useEffect, useRef } from "react";
import { db } from "@/firebase/firebase";
import { collection, query, orderBy, onSnapshot, doc, deleteDoc } from "firebase/firestore";
import { useRouter } from "next/navigation";
export default function BookmarksPage() {
const [bookmarkedPosts, setBookmarkedPosts] = useState([]);
const router = useRouter();
// 前回取得したデータを文字列で保持するための ref
const prevDataRef = useRef("");
useEffect(() => {
const q = query(
collection(db, "bookmarks"),
orderBy("createdAt", "desc")
);
const unsubscribe = onSnapshot(
q,
{ includeMetadataChanges: false },
(snapshot) => {
const bookmarksData = snapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id
}));
// 取得データを JSON 文字列に変換して比較
const newDataStr = JSON.stringify(bookmarksData);
if (newDataStr === prevDataRef.current) {
console.log("データが同じため更新しません");
return;
}
prevDataRef.current = newDataStr;
setBookmarkedPosts(bookmarksData);
console.log("取得されたブックマークデータの個数:", bookmarksData.length);
}
);
return () => unsubscribe();
}, []);
const handleDeleteBookmark = async (bookmarkId) => {
try {
const bookmarkDocRef = doc(db, "bookmarks", bookmarkId);
await deleteDoc(bookmarkDocRef);
alert("ブックマークを削除しました!");
} catch (error) {
console.error("削除エラー:", error.message);
}
};
return (
<div className="p-4 space-y-5 ">
<h2 className="text-4xl font-bold text-black">ブックマーク一覧</h2>
{/* カードを横並びにするコンテナ */}
<div className=" space-x-6 ">
{bookmarkedPosts.map((post) => (
<div
key={post.id}
className="flex flex-col w-370 border p-4 rounded-md bg-white "
>
<h4>{post.title}</h4>
<p>{post.comment}</p>
<p>{post.artist?.name || "アーティスト未選択"}</p>
{post.artist?.imageUrl && (
<img
src={post.artist.imageUrl}
alt={post.artist.name}
className="w-20 h-20 rounded-full mt-2"
/>
)}
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500"
>
{post.url}
</a>
<div className="mt-2">
<button
onClick={() => handleDeleteBookmark(post.id)}
className="ml-340 w-20 bg-red-500 text-white px-4 py-2 rounded"
>
Delete
</button>
</div>
</div>
))}
</div>
<button
onClick={() => router.push("/")}
className="ml-170 bg-blue-500 hover:bg-blue-800 text-white px-4 py-2 rounded mt-4"
>
ホームに戻る
</button>
</div>
);
}
該当するソースコード③
【components/PopularArtist.js】
"use client";
import { useState, useEffect, useCallback } from "react";
const defaultImage = "/images/white-icon.png";
export default function PopularArtists({ onSelect }) {
const [artists, setArtists] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
// アクセストークンを取得する関数
const getAccessToken = async () => {
try {
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Basic " + btoa(clientId + ":" + clientSecret),
},
body: "grant_type=client_credentials",
});
const data = await response.json();
return data.access_token;
} catch (error) {
console.error("アクセストークンの取得に失敗しました", error);
return null;
}
};
// 人気アーティストを取得する関数(useCallbackでメモ化)
const fetchPopularArtists = useCallback(async () => {
const accessToken = await getAccessToken();
if (!accessToken) return;
try {
const response = await fetch(
"https://api.spotify.com/v1/browse/new-releases?country=US&limit=20",
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
const data = await response.json();
const artistIds = [
...new Set(
data.albums.items.flatMap((album) =>
album.artists.map((artist) => artist.id)
)
),
].slice(0, 22);
const artistRes = await fetch(
`https://api.spotify.com/v1/artists?ids=${artistIds.join(",")}`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
const artistData = await artistRes.json();
// "image" プロパティを "imageUrl" に変更
const artistList = artistData.artists.map((artist) => ({
id: artist.id,
name: artist.name,
imageUrl:
artist.images && artist.images.length > 0
? artist.images[0].url
: defaultImage,
}));
setArtists(artistList);
setLoading(false);
} catch (error) {
console.error("人気アーティストの取得に失敗しました", error);
setLoading(false);
}
}, []);
// 検索クエリの変更に応じた検索
const searchArtists = async (query) => {
const accessToken = await getAccessToken();
if (!accessToken) return;
try {
const response = await fetch(
`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=artist&limit=10`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
const data = await response.json();
const searchedArtists = data.artists.items.map((artist) => ({
id: artist.id,
name: artist.name,
imageUrl:
artist.images && artist.images.length > 0
? artist.images[0].url
: defaultImage,
}));
setArtists(searchedArtists);
} catch (error) {
console.error("アーティスト検索に失敗しました", error);
}
};
const handleSearchChange = (event) => {
const query = event.target.value;
setSearchQuery(query);
if (query) {
searchArtists(query);
} else {
fetchPopularArtists();
}
};
useEffect(() => {
fetchPopularArtists();
}, [fetchPopularArtists]);
if (loading) return <div>アーティストを読み込み中...</div>;
return (
<div>
<input
type="text"
value={searchQuery}
onChange={handleSearchChange}
placeholder="アーティストを検索"
className="p-2 border rounded mb-4"
/>
<div className="flex flex-wrap gap-4">
{artists.map((artist) => (
<div
key={artist.id}
className="cursor-pointer border p-2 rounded flex flex-col items-center"
onClick={() => onSelect(artist)}
>
<img
src={artist.imageUrl}
alt={artist.name}
className="w-16 h-16 object-cover rounded-full"
/>
<span className="mt-2 text-sm">{artist.name}</span>
</div>
))}
</div>
</div>
);
}
該当するソースコード④
【app/music/page.js】
"use client";
import { useState, useEffect } from "react";
import { db, auth } from "@/firebase/firebase";
import { collection, addDoc, query, orderBy, onSnapshot, deleteDoc, doc } from "firebase/firestore";
import PopularArtists from "@/components/PopularArtists";
import { useRouter } from "next/navigation";
import { serverTimestamp } from "firebase/firestore";
export default function MusicPage() {
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [comment, setComment] = useState("");
const [selectedArtist, setSelectedArtist] = useState(null);
const [posts, setPosts] = useState([]);
const router = useRouter();
// 投稿データの取得
useEffect(() => {
const q = query(collection(db, "music"), orderBy("createdAt", "desc"));
const unsubscribe = onSnapshot(q, (querySnapshot) => {
const postsData = [];
querySnapshot.forEach((doc) => {
postsData.push({ ...doc.data(), id: doc.id });
});
setPosts(postsData);
}, (error) => {
console.error("データ取得エラー:", error);
});
return () => unsubscribe();
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
try {
await addDoc(collection(db, "music"), {
title,
url,
comment,
userId: auth.currentUser?.uid,
artist: selectedArtist, // ここに { id, name, imageUrl } のオブジェクトが入る
createdAt: serverTimestamp(),
});
setTitle("");
setUrl("");
setComment("");
setSelectedArtist(null);
alert("投稿完了!");
router.push("/");
} catch (error) {
console.error("投稿エラー:", error.message);
}
};
// ブックマーク追加
const handleBookmark = async (post) => {
try {
await addDoc(collection(db, "bookmarks"), {
...post,
userId: auth.currentUser?.uid,
createdAt: serverTimestamp(),
});
alert("ブックマークしました!");
} catch (error) {
console.error("ブックマークエラー:", error.message);
}
};
// 投稿削除
const handleDeletePost = async (postId) => {
try {
const postDocRef = doc(db, "music", postId);
await deleteDoc(postDocRef);
alert("投稿が削除されました!");
} catch (error) {
console.error("投稿削除エラー:", error.message);
}
};
return (
<div
className="p-4 space-y-4 bg-cover bg-center min-h-screen"
style={{ backgroundImage: 'url("/images/white_00115.jpg")' }}
>
{/* ユーザー入力フォーム */}
<h2 className="text-3xl ml-8 font-bold text-black">Lets share the music!</h2>
<form onSubmit={handleSubmit} className="space-y-4 bg-white bg-opacity-70 p-6 rounded-lg shadow-lg">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="タイトル"
className="border p-2 ml-2 rounded w-370"
/>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="URL"
className="border p-2 ml-2 rounded w-370"
/>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="コメントまたは感想を入力"
className="border p-2 ml-2 rounded w-370"
/>
<div className="ml-2">
<h3 className="text-3xl font-semibold mb-2 text-black">Please select the artist:</h3>
<PopularArtists onSelect={(artist) => setSelectedArtist(artist)} />
{selectedArtist && (
<p className="pt-2 font-bold text-black">Selected artist: {selectedArtist.name}</p>
)}
</div>
{/* 投稿ボタン */}
<button type="submit" className="ml-193 bg-blue-500 hover:bg-blue-800 text-white px-4 py-2 rounded">
POST
</button>
</form>
<div className="mt-5">
<h3 className="text-3xl ml-10 font-bold text-black">Posts list</h3>
<div className="space-y-3">
{posts.map((post) => (
<div key={post.id} className="w-350 ml-10 border p-4 rounded-md bg-white">
<h4>{post.title}</h4>
<p>{post.comment}</p>
<p>{post.artist?.name || "アーティスト未選択"}</p>
{post.artist?.imageUrl && (
<img
src={post.artist.imageUrl}
alt={post.artist.name}
className="w-16 h-16 rounded-full mt-2"
/>
)}
<a href={post.url} target="_blank" rel="noopener noreferrer" className="text-blue-500">
{post.url}
</a>
<div className="flex ml-60">
<button
onClick={() => handleBookmark(post)}
className="bg-yellow-500 text-white px-4 py-2 ml-230 rounded"
>
Bookmark
</button>
<button
onClick={() => handleDeletePost(post.id)}
className="bg-red-500 text-white px-4 py-2 ml-2 rounded"
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
{/* ホームページに戻るボタン */}
<button
onClick={() => router.push("/")}
className="bg-blue-500 hover:bg-blue-800 text-white ml-200 justify-center items-center px-4 py-2 rounded mt-4"
>
HOME
</button>
</div>
);
}
自分で試したこと
1Firebase onSnapShotがレンダリングの際に多重登録されてるか
2Keyが一意であるか
3useEffectの依存配列を確認
4再レンダリング時に再実行されるないかを確認するためにuseEffectの依存配列が [] になっているか(useEffectの中にconsole.logを仕込んで、コンソール上でいつ発火しているかを確認)
など様々な可能性を考えて修正してみましたが一向に解決しませんでした
0 likes