@Elur97

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

Firebaseから得た入力が重複して表示されてしまう

解決したいこと

ブックマーク一覧が一つだけ表示されるようにしたい。

発生している問題・エラー

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

1Answer

Comments

  1. @Elur97

    Questioner

    そうですね、そこは完全に間違いだったため訂正しました。しかし症状は改善せず…

    別件ですが、②の33行目でデバッグ用に差し込んだ、console.log("取得されたブックマークデータの個数:", bookmarksData.length);の出力をコンソール上で確認すると、6個のブックマークに対する出力が6回されているようでした。
    この部分が問題かもしれません。以下画像を添付しましたので確認をお願い致します。

  2. <MusicCard key={bookmark.id} music={bookmark} />がmapで複数配置されていて、MusicCardの中でさらに.mapを使っているのでそうなっているのだと思います。
    ②の引数でデータを受け取り.map部分を消すか①の.map部分を消せばいいと思います

  3. @Elur97

    Questioner

    ありがとうございました!!ご助言通り実装したら無事重複なく表示されようになりました!

Your answer might help someone💌