いいね

1import { doc, getDoc, runTransaction } from 'firebase/firestore';
2import { HiHeart, HiOutlineHeart } from 'react-icons/hi';
3import { useDebouncedCallback } from 'use-debounce';
4import useSWR from 'swr';
5import { db } from '../firebase/client';
6import { Post } from '../types/post';
7
8type Props = {
9  userId: string;
10  postId: string;
11};
12
13const Like = ({
14  userId = 'MOCK_USER_ID',
15  postId = '5BlpSpkJhCcVnsi9NuW4',
16}: Props) => {
17  const postRef = doc(db, `posts/${postId}`);
18
19  const postSWR = useSWR<Post>(`posts/${postId}`, async () => {
20    const snap = await getDoc(postRef);
21    return snap.data() as Post;
22  });
23
24  const likeRef = doc(db, `posts/${postId}/likes/${userId}`);
25
26  const isLikeSWR = useSWR<boolean>(
27    `posts/${postId}/likes/${userId}`,
28    async () => {
29      const snap = await getDoc(likeRef);
30      return snap.exists();
31    }
32  );
33
34  const isLike = isLikeSWR.data;
35  const oldData = postSWR.data;
36
37  const toggleLike = useDebouncedCallback(async () => {
38    if (isLike === undefined || !oldData) {
39      return null;
40    }
41
42    postSWR.mutate(
43      {
44        ...oldData,
45        likeCount: oldData.likeCount + (isLike ? -1 : 1),
46      },
47      {
48        revalidate: false,
49      }
50    );
51
52    isLikeSWR.mutate(!isLike, {
53      revalidate: false,
54    });
55
56    runTransaction(db, async (transaction) => {
57      const likeDoc = await transaction.get(likeRef);
58      const postData = (await transaction.get(postRef)).data() as Post;
59
60      if (likeDoc.exists()) {
61        transaction.delete(likeRef);
62      } else {
63        transaction.set(likeRef, {
64          userId,
65        });
66      }
67
68      const oldLikeCount = postData.likeCount || 0;
69
70      return transaction.update(postRef, {
71        likeCount: oldLikeCount + (likeDoc.exists() ? -1 : 1),
72      });
73    });
74  }, 500);
75
76  return (
77    <div>
78      <button
79        className="inline-flex space-x-2 items-center"
80        onClick={toggleLike}
81      >
82        {isLike ? (
83          <HiHeart className="text-pink-500" size={20} />
84        ) : (
85          <HiOutlineHeart className="text-gray-500" size={20} />
86        )}
87        <span>いいね</span>
88        <span>{postSWR.data?.likeCount}</span>
89      </button>
90    </div>
91  );
92};
93
94export default Like;

解説

仕組み

いいねを行う場合以下の処理が発生します。

  1. 該当記事のFirestoreドキュメント に属する likesサブコレクションいいねしたユーザーのID を格納
  2. 1 と同時に 該当記事のFirestoreドキュメントlikeCount+1 する

いいねを外す場合、上記とは逆の処理が発生します。

  1. 該当記事のFirestoreドキュメント に属する likesサブコレクション から いいねしたユーザーのID を削除
  2. 1 と同時に 該当記事のFirestoreドキュメントlikeCount-1 する

ポイント

  • アプリケーションの利用中無駄な再リクエストを発生させないために SWR を使用しています。
  • いいねの切り替えを即時に反映させるために ミューテーション を使っています。
  • 記事ドキュメントのいいね数更新likesサブコレクションへの追加(削除) をワンセットで行うために トランザクション を使用しています。
  • use-debounce を使って高速連打を一回のクリックとして処理しています。

使い方

  1. サンプルコードベースに いいねボタンコンポーネント を作成する。
  2. 記事一覧ページや記事詳細ページで使用します。(サンプルコードではダミーのユーザーIDや記事IDを初期値として使用しています)

使用ライブラリ

参考サイト