【備忘録】日本一わかりやすいReact-Redux講座 実践編 #11 「Firestoreでリアルタイムに表示を更新しよう」

## はじめに
### 概要
この記事は、トラハック氏([@torahack_](https://twitter.com/torahack_))が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『[日本一わかりやすいReact-Redux講座 実践編](https://www.youtube.com/watch?v=8fNgXnVTULU)』の学習備忘録です。

前回の講座で、Drawerメニューを作りました。

今回の講座では、**Firestoreを用いた「カートへの商品追加」**を実装します。

※ 前回記事: [【【備忘録】日本一わかりやすいReact-Redux講座 実践編 #10 「Drawerメニューを作ろう」](https://qiita.com/ddpmntcpbr/items/7d093e711067da415093)


### 動画URL
[Firestoreでリアルタイムに表示を更新しよう【日本一わかりやすいReact-Redux講座 実践編#11】](https://www.youtube.com/watch?v=geDm_4GQjEQ)

### 要点
- Firestore の ``onSnapshot`` メソッドを用いることで、DBの変更を自動検知する**リスナー機能**を作ることができる。

### 完成系イメージ

**``http://localhost:3000/``**
![11-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/aafd24e6-fc3d-6333-9219-c7e5692453e0.gif)

サイズテーブル内の「ショッピングカートアイコン」をクリックすると、該当する商品情報が Cloud Firestore に送信され、usersコレクションのサブコレクションにあたる``cartサブコレクション``に保存されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/b5e2d3f2-8337-d01e-a337-deb922895af8.png)

Firebaseコンソールから確認すると、認証ユーザーのサブコレクションとして、対象商品の商品情報が``cartサブコレクション``に保存されています。

加えて、Cloud Firestore 側と クライアント(React)側でリアルタイムな同期がなされるよう実装されています。これにより``cartサブコレクション``の追加・変更をクライアンド側が即時検知できるため、「ショッピングカートアイコン」をクリックした瞬間に、ヘッダー内のカートアイコンのバッチの値が+1されています。

## メイン

### cartサブコレクションの設計

カート情報を扱うにあたり、「どの商品、どのサイズをカートに入れたか」という情報を保存する場所（コレクション）を DB(Cloud Firestore) に用意する必要があります。

カート情報はユーザー一人一人に紐づくデータと考えられるので、**usersコレクションのサブコレクションとして、``cartサブコレクション``**を用意します。

言い換えると、``cartサブコレクション``は、``productsコレクション``とは完全に独立したコレクションとして存在することなるため、注意が必要です。

```:DB設計
productsコレクション
usersコレクション
  └── cartsサブクレクション
```

「この商品をカートに入れる」という処理は、該当する商品情報を``productsコレクション``から取り出し、それを認証ユーザーの``cartサブコレクション``に追加する、という流れで実現されます。

また、``Redux Store``についても同様です。カート情報の取り扱いは、全て**users関連のreduckファイル**で定義していきます。**products関連**ではないため、注意が必要です。

### onSnapshotによるリアルタイム同期

「完成系イメージ」でもお見せした通り、ヘッダーのカートアイコンの右肩には、追加した商品の個数が表示されています。

言い換えると、「**現時点における**、cartサブコレクション内のドキュメントの数」を、ここに表示しています。

しかし、この**現時点における**という処理は、Webアプリにとって少し厄介なものです。

基本的に、WebアプリでDB情報を利用するためには、「1. アプリ側からDBに対してリクエストを送り」、「2. DBが結果をレスポンスとして返す」、という流れになるのが一般的です。

言い換えると、**アプリ側からリクエストを出さない限り、DB側から自発的に動くということは(基本的には)ありません**。「DBさん、もし情報の変更とかあったら、そっちから連絡ください」というわけには行きません。

(※ 大昔の掲示板サイト等では、誰かが新規で書き込みをしても、ブラウザの再読み込みをしない限り、こちらの画面では新規の書き込みを見ることができませんでした。これは、アプリ側から再度リクエストを送らない限り、新しい情報をDBから取得できない、ということを表しています)

すなわち、**現時点における**DB情報をWebアプリ側で使用するためには、Webアプリ側で「**DBの情報を常時監視する**」ような処理を別途実装する必要があり、それを実現する機能(または関数)のことを**``リスナー``**といいます。

通常、このような「クライアントとDBとのリアルタイムな同期」を実装するのは難しいのですが、Firebaseでは、Firestore内の``onSnapshot``というメソッドを用いることで、とても簡単に実装することができます（詳細は後述）

### リスナーはどこで定義すべき？

今回は、``<HeaderMenu>``コンポーネント内で定義します。理由は単純に「リアルタイム同期の結果は、``<HeaderMenu>``コンポーネントが描画されている間のみ利用するものだから」です。

``useEffect()``を用いて、コンポーネントの初回レンダー時において、リスナーが定義されるようにします。もし、``<HeaderMenu>``コンポーネントがレンダーされている最中にDB側で何らかの変更があった際、リスナーが変更を検知し、結果を即時 Redux Store に反映します。

また、``<HeaderMenu>``のレンダーが終了するタイミングで、DBとの同期が解除されるように設定します。これにより、DB情報が不必要な状況における無駄な通信を防ぐことができます。

### 実装

いよいよファイルの実装に移ります。

```:実装ファイル
1.src/templates/ProductDetail.jsx
2.src/components/Products/SizeTable.jsx
3.src/components/Header/HeaderMenus.jsx
4.src/reducks/store/initialState.js
5.src/reducks/users/operations.js
6.src/reducks/users/actions.js
7.src/reducks/users/reducers.js
8.src/reducks/users/selectors.js
```

実装概要を説明します。

``1.src/templates/ProductDetail.jsx``において、``productsコレクション``から該当する商品情報を取り出して``cartサブコレクション``へ保存するための配列として整形する、``addProduct()``関数を定義します。

ここで作られた``cart``用の配列は、``5.src/reducks/users/operations.js``内で定義する``addProductToCart()``に渡り、Cloud Firestoreへ送信され、``cartサブコレクション``に保存されます。

その後、``3.src/components/Header/HeaderMenus.jsx``をレンダーしたときに定義されたリスナーが``cartサブコレクション``のドキュメント追加を検知します。

本コンポーネント内では、現時点での Redux Store 内の``users[:cart]``を取得するセレクター(``getProductsInCart(selector)``)を使用していますのでが、それに検知した内容を新たに追加した配列を用意します。

最後に、この配列を``5.src/reducks/users/operations.js``内にもう一つ新たに定義した``fetchProductsInCart()``に渡し、reducers を通じて Redux State の カート情報が更新されます。これを再び``3.src/components/Header/HeaderMenus.jsx``のセレクターで再取得することで、カートアイコンの右肩の数字が+1される、という流れです。


```jsx:1.src/templates/ProductDetail.jsx
import React,{ useState,useEffect,useCallback } from "react"; // 追記
.
.
.
import {db, FirebaseTimestamp} from "../firebase" // 追記
.
.
.
import {SizeTable} from "../components/Products" // 追記
import {addProductToCart} from "../reducks/users/operations" // 追記

.
.
.

const ProductDetail = () => {
  const classes = useStyles();
  const dispatch = useDispatch();
  const selector = useSelector((state)=>state);
  const path = selector.router.location.pathname;
  const id = path.split("/product/")[1];

  const [product,setProduct] = useState(null);

  useEffect(()=>{
    db.collection("products").doc(id).get()
    .then(doc => {
      const data = doc.data();
      setProduct(data)
    })
  },[]);

 // 追記
  const addProduct = useCallback((selectedSize) => {
    const timestamp = FirebaseTimestamp.now();
    dispatch(addProductToCart({
      added_at: timestamp,
      description: product.description,
      gender: product.gender,
      images: product.images,
      name: product.name,
      price: product.price,
      productId: product.id,
      quantity: 1,
      size: selectedSize
    }))
  },[product]);
 // 追記ここまで
 
  return (
    <section className="c-sention-wrapin">
      {product && (
        <div className="p-grid__row" >
          <div className={classes.sliderBox}>
            <ImageSwiper images={product.images} />
          </div>
          <div className={classes.detail}>
            <h2 className="u-text__headline">{product.name}</h2>
            <p className={classes.price}>{product.price.toLocaleString()}</p>
            <div className="module-spacer--small"></div>
            <SizeTable addProduct={addProduct} sizes={product.sizes}/> {*追記*}
            <div className="module-spacer--small"></div>
            <p>{returnCodeToBr(product.description)}</p>
          </div>
        </div>
      )}
    </section>
  )
};

export default ProductDetail
```

- ``useEffect()``により初回レンダー時に取得したDB上の``products``を用いて、カートに入れたい商品の情報を配列して生成して``addProductToCart()``渡す関数として、``addProducts()``を定義しています。
- 配列には、サイズ(S, M, Lなど)の情報も必要になるため、``addProducts()``を``useCallback()``で定義した上で、子コンポーネントの``<SizeTable>``へ渡しています。

```jsx:2.src/components/Products/SizeTable.jsx
.
.
.

const SizeTable = (props) => {
  const classes = useStyles();

  const sizes = props.sizes;

  return (
    <TableContainer>
      <Table>
        <TableBody>
          {sizes.length > 0 && (
            sizes.map(size => (
              <TableRow key={size.size}>
                <TableCell component="th" scope="row">{size.size}</TableCell>
                <TableCell>残り{size.quantity}点</TableCell>
                <TableCell className={classes.iconCell}>
                  {size.quantity > 0 ? (
                    <IconButton onClick={() => props.addProduct(size.size)}> {*追記*}
                      <ShoppingCartIcon />
                    </IconButton>
                  ) : (
                    <div>売切</div>
                  )}
                </TableCell>
                <TableCell className={classes.iconCell}>
                  <IconButton>
                    <FavoriteBorderIcon />
                  </IconButton>
                </TableCell>
            </TableRow>
            ))
          )}
        </TableBody>
      </Table>
    </TableContainer>
  );
};

export default SizeTable;
```

- ショッピングカートアイコンのonClickイベントに、先ほど親コンポーネントで定義した``addProducts()``を設定します。

```jsx:3.src/components/Header/HeaderMenus.jsx
import React, {useEffect} from "react"; // 追記
import IconButton from "@material-ui/core/IconButton";
import Badge from "@material-ui/core/Badge";
import ShoppingCartIcon from "@material-ui/icons/ShoppingCart";
import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder";
import MenuIcon from "@material-ui/icons/Menu"
import { getProductsInCart, getUserId } from "../../reducks/users/selectors"; // 追記
import {useSelector, useDispatch} from "react-redux"; // 追記
import {db} from "../../firebase"; // 追記
import {fetchProductsInCart} from  "../../reducks/users/operations"; // 追記

const HeaderMenu = (props) => {
　 // 追記
  const dispatch = useDispatch();
  const selector = useSelector((state)=>state)
  const uid = getUserId(selector)
  let productsInCart = getProductsInCart(selector)

  useEffect(() => {
    const unsubscribe = db.collection('users').doc(uid).collection('cart')
      .onSnapshot(snapshots => {

        snapshots.docChanges().forEach(change => {
          const product = change.doc.data();
          const changeType = change.type

        switch (changeType) {
          case 'added':
            productsInCart.push(product);
            break;
          case 'modified':
            const index = productsInCart.findIndex(product => product.cartId === change.doc.id)
            productsInCart[index] = product;
            break;
          case 'removed':
            productsInCart = productsInCart.filter(product => product.cartId !== change.doc.id);
            break;
          default:
            break;
        }
      });

      dispatch(fetchProductsInCart(productsInCart))
    });

    return () => unsubscribe()
  },[]);
　 // 追記ここまで

  return (
    <>
      <IconButton>
        <Badge badgeContent={productsInCart.length} color="secondary"> {*追記*}
          <ShoppingCartIcon />
        </Badge>
      </IconButton>
      .
      .
      .
    </>
  )
}

export default HeaderMenu
```

今回の肝！

``useEffect()``を用いて、DBのリスナー(常時監視)を定義しています。

Reactにおける、onSnapshotを用いたリスナー定義を一般化すると以下のようなイメージ。

```js:ReactにおけるonSnapshot利用一般化
useEffect(() => {
    const unsubscribe = db.collection('xxx')
      .onSnapshot(snapshots => {

        snapshots.docChanges().forEach(change => {
          const data = change.doc.data();
          const changeType = change.type

        switch (changeType) {
          case 'added':
            // ドキュメントが追加された時の処理
            break;
          case 'modified':
            // ドキュメントが変更された時の処理
            break;
          case 'removed':
            // ドキュメントが削除された時の処理
            break;
          default:
            break;
        }
      });

      // リスナーにより取得した情報を処理
    });
   return () => unsubscribe()
  },[]);
```

``changeType ``は、DB上で操作が加えれたドキュメントにおいて、操作内容が新規作成(**added**)、修正(**modified**)、削除(**removed**)のうちどれが行われたかの、情報が含まれています。

なお、**初期レンダー時点で最初からにDB内に含まれていたドキュメントは、全て`added`情報を持つドキュメントとして判定されます。**

コンポーネントの初期レンダー時、及びDB内での情報変更が発生したときに、``unsubscribe``が動作する仕組みになっています。この辺りのリスナーの動作は、実装が終わった最後に確認します。

また、最後に``return () => unsubscribe()``と書くことで、コンポーネントの描画が終了したタイミングでリスナー監視が解除されるようになります(これを設定しないと、該当コンポーネントの再描画するたびにリスナーが2つも3つも作成・実行されることになるので、動作処理が大変なことになります。)

今回はカート情報へのリスナーですが、監視対象のデータが変わったとしても、ほぼ上記のテンプレート通りに書いていくことになると思います。リスナーを設定したいコンポーネントの``useEffect()``(あるいは``componentDidMount()``)で定義しましょう。

さて、``<HeaderMenu>``コンポーネントに戻ります。コンポーネントの初期レンダー時、およびリスナーがDBの変更を検知したとき、検知した内容(changeType)に応じた配列処理を行い、カート情報を``fetchProductsInCart()``へ渡します(後に定義)。

これにより、Redux Store内の``users[:cart]``が更新されます。

```js:4.src/reducks/store/initialState.js
const initialState = {
  products: {
    list: []
  },

  users: {
    cart: [], //追記
    isSignedIn: false,
    role:  "",
    uid: "",
    username: ""
  }
};

export default initialState

```

- ``users[:cart]``を追加します。ここに、カート情報が配列として入ります。

```js:5.src/reducks/users/operations.js
import { fetchProductsInCartAction,signInAction,signOutAction } from "./actions";
import { push } from "connected-react-router";
import {auth, db, FirebaseTimestamp} from "../../firebase/index"

export const addProductToCart = (addedProduct) => {
  return async (dispatch,getState) => {
    const uid = getState().users.uid;
    const cartRef = db.collection("users").doc(uid).collection("cart").doc();
    addedProduct["cartId"] = cartRef.id;
    await cartRef.set(addedProduct)
    dispatch(push("/"))
  }
}

export const fetchProductsInCart= (products) => {
  return async(dispatch) => {
    dispatch(fetchProductsInCartAction(products))
  }
}
.
.
.
```

``<ProductDetail>``で使用する``addProductToCart()``と、``<HeaderMenu>``で使用する``fetchProductsInCart``をそれぞれ定義してます。

``addProductToCart()``は、受け取った配列(カートに入れたい商品の情報)をDB上に保存しています。

``fetchProductsInCart``は、リスナー結果をStoreに反映することが目的のため、そのままアクションへ渡しています。


```js:6.src/reducks/users/actions.js
export const FETCH_PRODUCTS_IN_CART = "FETCH_PRODUCTS_IN_CART";
export const fetchProductsInCartAction = (products) => {
  return {
    type: "FETCH_PRODUCTS_IN_CART",
    payload: products
  }
};
.
.
.
```

```js:7.src/reducks/users/reducers.js
import * as Actions from './actions'
import initialState from '../store/initialState'

export const UsersReducer = (state = initialState.users, action) => {
  switch (action.type) {
    case Actions.FETCH_PRODUCTS_IN_CART:
      return {
        ...state,
        cart: [...action.payload]
      };
    case Actions.SIGN_IN:
      return {
        ...state,
        ...action.payload
      };
    case Actions.SIGN_OUT:
      return {
        ...action.payload
      };
    default:
      return state
  }
}
```


```js:8.src/reducks/users/selectors.js
import { createSelector } from "reselect";

const usersSelector = (state) => state.users;
.
.
.
export const getProductsInCart = createSelector(
  [usersSelector],
  state => state.cart
)
.
.
.
```

- ``users[:cart]``用のアクション、リデューサー、セレクターを定義します。

###　動作確認

全体の動作は「完成系イメージ」の通りです。

ここでは別途、``onSnapShot``によるDBのリスナーが機能しているか、確認します。``<HeadeMenu>``のリスナー関数内部に``console.log()``を入れてみます。

```jsx:src/components/Header/HeaderMenus.jsx
        snapshots.docChanges().forEach(change => {
          const product = change.doc.data();
          const changeType = change.type
          console.log({商品名: product.name, サイズ: product.size, changeType: changeType}) //追記
```

#### 初期レンダー & 新規作成

![11-5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/b95ee385-d4ba-af99-96c7-b8d405a30ce5.gif)

ブラウザを再読み込みし、``<HeadeMenu>``を再レンダーすると、すでにDB内に保存されていたドキュメントが、全て``added``として処理されています。

また、実際にサイズテーブル内のカートアイコンをクリックすると、DBが更新されると同時に該当商品のドキュメントが``added``属性を持ってリスナーで検知されています(そしてStoreへ反映され、ヘッダーのカートアイコンバッジが+1される、という流れです)

#### 修正

![11-6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/f86f8bf5-a323-f4d9-c248-c2d3570d0ba8.gif)

カート内部の情報をアプリ側から修正する機能は実装していないので、Firebaseコンソールから直接、修正操作を加えています。``modified``が検知されています。

#### 削除

![11-7.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/db839100-d53e-d676-9cb8-112982f5cccd.gif)

``removed``が検知されています。

## さいごに
今回の要点をおさらいすると、

- Firestore の ``onSnapshot`` メソッドを用いることで、DBの変更を自動検知する**リスナー機能**を作ることができる。

以上です！onSnapshot を用いれば、SlackやLINEを模した「リアルタイムチャットアプリ」などを簡単に作れそうですね

このような学習内容を日々呟いていますので、よろしければTwitter([@ddpmntcpbr](https://twitter.com/ddpmntcpbr))のフォローもよろしくお願いします。