@Elur97

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

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

解決したいこと

ユーザーがブックマークした一覧を重複なしで一つだけ表示されるようにしたい。

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

Firebase Firestoreに保存されているブックマーク済みの投稿データを取得し、そのデータをまとめて表示するために書いたコードです。以下の画像参照していただければ分かる通り、同じ内容のブックマーク一覧が何回も繰り返し表示されるようになってしまいました。

1000000523.png

該当するソースコード

import { useState, useEffect, memo } from "react";
import { db } from "@/firebase/firebase";
import { collection, query, orderBy, onSnapshot } from "firebase/firestore";
import { useRouter } from "next/navigation";
import { doc, deleteDoc } from "firebase/firestore";

const BookmarkItem = memo(({ post, onDelete }) => {
return (

{post.title}

{post.comment}

{post.artist?.name || "アーティスト未選択"}

{post.artist?.imageUrl && ( {post.artist.name} )} {post.url}
onDelete(post.id)} className="ml-340 w-20 bg-red-500 text-white px-4 py-2 rounded" > Delete
); });

BookmarkItem.displayName = 'BookmarkItem';

export default function BookmarksPage() {
const [bookmarkedPosts,setBookmarkedPosts] = useState([]);
const router = useRouter();

useEffect(() => {
const q = query(collection(db, "bookmarks"), orderBy("createdAt", "desc"));

const unsubscribe = onSnapshot(q, (snapshot) => {
const updatedPosts = snapshot.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}));

// ユニークなデータを取得
const uniquePosts = updatedPosts.filter((value, index, self) =>
index === self.findIndex((t) => t.id === value.id)
);

setBookmarkedPosts(uniquePosts);
}, (error) => {
console.error("Snapshot error:", error);
});

return () => {
unsubscribe();
};
},[]);

const handleDeleteBookmark = async (bookmarkId) => {
try {
const bookmarkDocRef = doc(db, "bookmarks", bookmarkId);
await deleteDoc(bookmarkDocRef);

// ローカル状態の更新
setBookmarkedPosts((prevPosts) => prevPosts.filter((post) => post.id !== bookmarkId));

alert("ブックマークを削除しました!")
} catch (error) {
console.error("削除エラー:", error.message);
}
};

return (

ブックマーク一覧

{bookmarkedPosts.map((post) => ( ))}
router.push("/")} className="ml-170 bg-blue-500 hover:bg-blue-800 text-white px-4 py-2 rounded mt-4" > ホームに戻る
); }

自分で試したこと

1 Firebase onSnapShotがレンダリングの際に多重登録されてるか
2 Keyが一意であるか
3 useEffectの依存配列を確認
4再レンダリング時に再実行されるないかを確認するためにuseEffectの依存配列が [] になっているか確認(useEffectの中にconsole.logを仕込んで、コンソール上でいつ発火しているかを確認)
など様々な可能性を考えて修正してみましたが一向に解決しませんでした。

0 likes

1Answer

よくわからないですが、3つのブックマークを表示する所をブックマーク一覧を3回表示するような処理にしてませんか?

0Like

Comments

  1. @Elur97

    Questioner

    その処理を実装した覚えがなく、一日中原因を探してました…
    一応長くなりますが、下に関連するコードを並べておきます。
    ーーーーーーーーーーーーーーーーーー

    import { useState, useEffect } from "react";
    import { db } from "@/firebase/firebase";
    import { collection, query, orderBy, onSnapshot } from "firebase/firestore";
    import { useRouter } from "next/navigation";
    import { doc, deleteDoc } from "firebase/firestore";

    export default function BookmarksPage() {
    const [bookmarkedPosts, setBookmarkedPosts] = useState([]);
    const router = useRouter();

    useEffect(() => {
    const q = query(collection(db, "bookmarks"), orderBy("createdAt", "desc"));
    const unsubscribe = onSnapshot(q, (snapshot) => {
    snapshot.docChanges().forEach((change) => {
    if (change.type === "added") {
    setBookmarkedPosts((prev) => [...prev, { ...change.doc.data(), id: change.doc.id }]);
    }
    if (change.type === "modified") {
    setBookmarkedPosts((prev) =>
    prev.map((post) =>
    post.id === change.doc.id ? { ...change.doc.data(), id: change.doc.id } : post
    )
    );
    }
    if (change.type === "removed") {
    setBookmarkedPosts((prev) => prev.filter((post) => post.id !== change.doc.id));
    }
    });
    });

    return () => unsubscribe();
    

    }, []);

    const handleDeleteBookmark = async (bookmarkId) => {
    try {
    const bookmarkDocRef = doc(db, "bookmarks", bookmarkId);
    await deleteDoc(bookmarkDocRef);
    alert("ブックマークを削除しました!");
    } catch (error) {
    console.error("削除エラー:", error.message);
    }
    };

    return (


    ブックマーク一覧



    {bookmarkedPosts.map((post) => (

    {post.title}


    {post.comment}


    {post.artist?.name || "アーティスト未選択"}


    {post.artist?.imageUrl && (
    {post.artist.name}
    )}

    {post.url}


    <button
    onClick={() => handleDeleteBookmark(post.id)}
    className="ml-340 w-20 bg-red-500 text-white px-4 py-2 rounded"
    >
    Delete



    ))}

    <button
    onClick={() => router.push("/")}
    className="ml-170 bg-blue-500 hover:bg-blue-800 text-white px-4 py-2 rounded mt-4"
    >
    ホームに戻る


    );
    }
    ーーーーーーーーーーーーーーーーーー②
    "use client";
    import { useState, useEffect, useCallback } from "react";

    const clientId = "19cd03c8029640558542ae4692c26362";
    const clientSecret = "bec20dc135194be5ab0b71922b64c90c";
    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

    アーティストを読み込み中...
    ;

    return (




    {artists.map((artist) => (
    <div
    key={artist.id}
    className="cursor-pointer border p-2 rounded flex flex-col items-center"
    onClick={() => onSelect(artist)}
    >
    {artist.name}
    {artist.name}

    ))}


    );
    }
    ーーーーーーーーーーーーーーーーーー③"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) => (

    ))
    ) : (


    ブックマークされた投稿が表示されるまでしばらくお待ちください……


    )}

    );
    }
    ーーーーーーーーーーーーーーーーー④"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")' }}
    >
    {/* ユーザー入力フォーム /}

    Lets share the music!



    <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"
    />

    Please select the artist:


    setSelectedArtist(artist)} />
    {selectedArtist && (

    Selected artist: {selectedArtist.name}


    )}

    {/
    投稿ボタン */}

    POST

      <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>
    

    );
    }

Your answer might help someone💌