11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-08-15

はじめに

概要

この記事は、トラハック氏(@torahack_)が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。

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

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

※ 前回記事: 【【備忘録】日本一わかりやすいReact-Redux講座 実践編 #10 「Drawerメニューを作ろう」

動画URL

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

要点

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

完成系イメージ

http://localhost:3000/
11-1.gif

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

image.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される、という流れです。

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>へ渡しています。
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()を設定します。
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を用いたリスナー定義を一般化すると以下のようなイメージ。

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]が更新されます。

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

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

export default initialState

  • users[:cart]を追加します。ここに、カート情報が配列として入ります。
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に反映することが目的のため、そのままアクションへ渡しています。

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
  }
};
.
.
.
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
  }
}
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()を入れてみます。

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

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

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

修正

11-6.gif

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

削除

11-7.gif

removedが検知されています。

さいごに

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

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

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

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

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?