React+Firestoreでブックマーク(お気に入り)の保存機能を作成する

現在`ReactとFirestore`で作成しているブログのようなwebサービスの各投稿をブックマークして、別ページで閲覧できるようにするような機能を追加したので、その解説をします。

## 前提
解説をする上で、前提があります。

- ReactのFunction ComponentやHooks、contextをなんとなくでも使える段階にある
- Firestoreのcollectionやdocument、subcollectionについてそれらがどういうものかぼんやりとでも知っている
- Firestoreのデータを読み込んだり書き込んだりできる

上記の二つが前提となる知識です。

さらにこの記事では`大まかな実装の流れや考え方を解説するのでコードを省略している部分もあります`。
この記事だけで実装はできませんが、ブックマーク機能やお気に入り機能を実装する上でのヒントになると思っています。(人それぞれコードや実装の方法が違いますからね😄)

## 機能について
今回はブックマーク機能を実装すると言いましたが、どのようなものなのかは以下の画像をご覧ください。

![スクリーンショット 2020-11-25 16.06.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/509690/6ad8287d-6238-20d8-b349-c046462c5155.png)

各投稿に対し、右下にブックマークのiconを表示します。
このiconをクリックし、ブックマーク登録がされると色がつき、もう一度クリックすると枠だけのiconに戻ります。

このブックマークのiconには[material ui](https://material-ui.com/components/material-icons/)のiconを使います。

Material UIの導入については[こちらの記事](https://qiita.com/h-yoshikawa/items/efa33101b0a02cba7759)が参考になると思います。

また、保存した記事は画像の上部のheaderにある紙のようなiconをクリックすることで保存した記事を一覧表示するページに飛べます。

## 実装
さて、早速実装していきましょう。

### db
まず、Firestoreのdb設計についてですが、`users`コレクションの中にある各ユーザーに対して、サブコレクションとして`bookmarks`という名前のものを追加し、そこにブックマークした記事を保存していきます。

usersコレクション→それぞれのuserのドキュメント→bookmarksというサブコレクション→bookmarksのドキュメント

```
users - userID - bookmarks - document - id
                                      - post_id
                                      - authoName
                                      - createdAt
                                      - title     
```

投稿に対して、ブックマークを押したら現在ログインしているユーザーのデータに、ブックマークした記事のデータが保存される感じです。

### 一覧画面でのブックマーク
今回は、各投稿に対してブックマークボタンを配置しているので、投稿のcardを生成しているコンポーネントにブックマークのiconを追加する必要があります。

僕の場合はPostコンポーネントをforEachで回して記事を表示しているので、Postコンポーネントにiconを追加します。

```Post.jsx
//importやstyle含め、いろいろ省略しています。

const Post = ({ authorName, content, createdAt, title, id, uid}) => {
  const { currentUser, savePostToBookmark, removePostFromBookmark } = useAuth();
  const [saved, setSaved] = useState(false)

  //ここからが該当の場所です!!
  const savePost = () => {
    setSaved(true)
    const savedPosts = ({ authorName, content, createdAt, title, id});
    return savePostToBookmark(savedPosts)
  }

  const removeBookmark = async (id) => {
    setSaved(false)
    removePostFromBookmark(id)
  };
  
  //useEffect内では、ログアウトして再度ログインした際や、画面を再読み込みした際などにも
  //ブックマークした投稿が消えないようにするために、ログインユーザーのサブコレクションbookmarksを参照し、
  //もしpostのidと保存された投稿のpostのidが等しければsaved=trueとすることでブックマークされた状態を表示するようにしています。
  useEffect(() => {
    const uid = currentUser.uid
    db.collection('users').doc(uid).get()
    .then(doc => {
        if (doc.exists) {
          db.collection('users').doc(uid).collection('bookmarks').get()
          .then(snapshots => {
            snapshots.docs.forEach(doc => {
              const data = doc.data();

              const post_id = data.id
              const saveId = data.saveId
              if (saveId === id) {
                 setSaved(true)
                 setSavedId(saveId)
              }
            })
          })
        }
      })
    
  }, [saved])
  //ここまで

    return (
        <>
        <Card className={classes.root} variant="outlined">
            <CardContent style={{ paddingBottom: "0" }}>           
            {/*ここからが該当の場所です!!*/}
            {saved === true ? 
            <IconButton className={classes.likeBtn} onClick={removeBookmark}>
              <BookmarkIcon />
            </IconButton>
            :
            <IconButton className={classes.likeBtn} onClick={savePost}>
               <BookmarkBorderIcon />
            </IconButton>
            }
            {/*ここまで*/}
        </Card>
        </>
    )
}
```

#### 11/26 追記
`Post.jsx`のuseEffectの第二引数(deps)に`saved`を追加しました。depsは空配列の場合、マウント時、アンマウント時にuseEffectの第一引数を実行しますが、この場合、ブックマークをクリックした時に、そのブックマークのidを保存するサブコレクションのidが取得できておらず、何かPostコンポーネントが再びマウントされるような操作を行わない限り、idが取得できていない状態なので、firebaseの参照エラーが起きます。なので、今回はブックマークされるタイミングで、savedがfalseからtrueになるのをdepsで感知して、useEffectを実行するようにしました。追記以上です。

ブックマークされている状態のときは、`<BookmarkIcon />`、
ブックマークされていnaい状態のときは、`< BookmarkBorderIcon />`
を表示します。

では、どのようにしてブックマークされているのかどうかを判断しているのかというと、`saved`というstateのtrueかfalseによって判断しています。`const [saved, setSaved] = useState(false)`の部分で定義しています。初期値、つまり何もしていない状態ではもちろんブックマークは登録されていないので、savedの初期値は`false`を入れています。ブックマークが登録されたタイミングで、savedが`true`となり、iconに色がつきます(正確には塗り潰されたiconに入れ替わる)。

#### ブックマーク登録をし、dbにデータを追加する
ブックマークが登録されていない状態で、iconをクリックすると、savePostという関数が走ります。
この関数ではブックマークを押した投稿のデータをdbに保存します。もう一度コードを記載します。

```Post.jsx
 //クリックされたらこの関数が走る
  const savePost = () => {
    //まずはsavedをtrueに
    setSaved(true)
    //savedPostsはクリックされたiconの投稿の情報
    const savedPosts = ({ authorName, content, createdAt, title, id});
    //savedPostsをsavePostToBookmarkに渡して実行
    return savePostToBookmark(savedPosts)
  }
```

ここでの`savePostToBookmark`は、contextを用いて別のファイルで書いた関数です。内容は以下の通り。

```context.jsx

  const savePostToBookmark = async(savedPosts) => {
    const uid = currentUser.uid
    //saveRefではサブコレクションbookmarksを参照し、
    const saveRef = db.collection('users').doc(uid).collection('bookmarks').doc();
    //以下のコードで、savedPostsに自身のid(bookmarksのdocument id)を追加します。
    savedPosts['saveId'] = saveRef.id;
    //Post.jsxから渡ってきた情報(savedPosts)を保存します。
    await saveRef.set(savedPosts);
  }
```

これにてブックマークの登録は完了です。

#### ブックマーク登録を解除し、dbからデータを削除する
ここでは削除機能を実装します。

``` Post.jsx
  const removeBookmark = async (id) => {
    //まずはsavedをtrueに
    setSaved(false)
    //contextの関数にデータを渡す
    removePostFromBookmark(id)
  };
```
ここでも、`removePostFromBookmark`という関数をcontextから呼び出して使っています。

```context.jsx
  const removePostFromBookmark = id => {
    const uid = currentUser.uid
    //Post.jsxから渡ってきたデータ(今回はid)を元にdbから該当のデータを探し出し、delete()でデータを削除
    db.collection('users').doc(uid)
        .collection('bookmarks').doc(id)
        .delete();
  };
```
ブックマーク済みのiconをクリックすると`removeBookmark`が実行され、ブックマークのデータがdbから削除されます。この時に`setSaved(false)`でsavedをfalseとして表示されるbookmarkのiconを切り替えます。

以上がブックマークの登録と削除でした。

### ブックマークした投稿を別ページで表示しよう
ここでは、ブックマークした投稿を別のページで一覧表示する機能と、その画面でブックマークを解除した時に一覧から投稿が消えるような機能を実装します。

![スクリーンショット 2020-11-25 17.46.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/509690/4cec8aa9-1949-ff57-3bf6-3df39cbfae18.png)

#### ブックマークされた投稿を取得し表示する
ブックマークされた投稿を表示します。各投稿は子のコンポーネントとして別でコンポーネントを作成し、mapで回して表示します。(ブックマークした投稿が一つだけでも配列として保存しているのでmapかforEachする必要があります)

```BookmarkList.jsx
export default function BookmarkList() {
    const { currentUser } = useAuth()
    //dbから取り出した各bookamarkのデータを保持するためのstateを用意する
    const [bookmarks, setBookmarks] = useState([])

    useEffect(() => {
        const uid = currentUser.uid;
        let posts = [];
        //サブコレクションbookmarksを取得し
        db.collection('users').doc(uid).collection('bookmarks').get()
            .then(snapshots => {
                //forEachで中身を取り出す。
                snapshots.docs.forEach(doc => {
                    const data = doc.data()
                    //取り出したデータをpostsという配列にpush
                    posts.push({
                        authorName: data.authorName,
                        content: data.content,
                        createdAt: data.createdAt,
                        title: data.title,
                        saveId: doc.id,
                        uid: data.uid,
                        post_id: data.id
                    })
                    
                })
                //用意したbookmarksにstateでpostsのデータを保持する
                setBookmarks(posts)
            })
    }, [])

    return (
        <>
            <div style={{ marginTop: "100px" }}>
            <h3>保存した投稿</h3>
            {/*stateのbookmarksはforEachで回した分データを配列として持っているので、mapで取り出す*/}
            {bookmarks.map(bookmark => 
               {/*BookmarkListItemにデータを渡す*/}
                <BookmarkListItem 
                    key={bookmark.saveId}
                    authorName={bookmark.authorName}
                    content={bookmark.content}
                    createdAt={bookmark.createdAt}
                    title={bookmark.title}
                    // bookmarkのid
                    id={bookmark.saveId}
                    post_id={bookmark.post_id}
                />
            )}
            </div>
        </>
    )
}

```
続いて、`BookmarkListItem`です。
既にブックマークをされている投稿を表示しているので、ブックマークに追加するためのiconや関数は用意していません。

```BookmarkListItem.jsx
export default function BookmarkListItem({ authorName, content, createdAt, title, post_id, id}) {
    const { removePostFromBookmark } = useAuth()
    const [saved, setSaved] = useState(true)

    //ここではcontextから読み込んだブックマークを解除する関数を実行するためのコードを書きます。
    const removeBookmark = () => {
        //まずはsavedをfalseに
        setSaved(false)
        //contextから読み込んだremovePostFromBookmarkにidを渡し、ブックマークのデータをdbから削除
        removePostFromBookmark(id)
    };

    return (
        <>
        {/*このコンポーネントはブックマーク登録されていないものは表示する必要がないの*/}
        {/*そこで全体を条件式で囲ってsaved=trueの場合(つまり、ブックマークされている場合)のみ表示するようにしている*/}
        {saved === true && 
        <Card className={classes.root} variant="outlined">
            <CardContent style={{ paddingBottom: "0" }}>
                <Typography variant="h5" component="h3">
                  {title}
                </Typography>
                <Typography className={classes.pos} color="textSecondary">
                  {authorName+"・"+createdAt}
                </Typography>
                <Typography className={classes.contentText} variant="body2" component="p">
                  {content}
                </Typography>
            </CardContent>
            <CardActions className={classes.detailBtnWrap}>
              <Link to={'/detail/' + post_id} className={classes.detailLink}>
                <Button variant="contained" className={classes.detailButton} size="small">詳細を表示</Button>
              </Link>
            </CardActions>
            <IconButton className={classes.likeBtn} onClick={() => removeBookmark(id)}>
               <BookmarkIcon />
            </IconButton>
        </Card>
        }
        </>
    )
}
```

こんな感じで実装は完了です。

## 最後に
いろいろ省略して解説してきましたが、ブックマークやお気に入りしてそれらを一覧表示したいという方の役に立てたら幸いです。

また、「ここはこういう書き方の方がいい」みたいなコードの提案や、間違ってる部分の指摘など、コメントでお待ちしております。