はじめに
概要
この記事は、トラハック氏(@torahack_)が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。
前回の講座で、Drawerメニューを作りました。
今回の講座では、**Firestoreを用いた「カートへの商品追加」**を実装します。
※ 前回記事: 【【備忘録】日本一わかりやすいReact-Redux講座 実践編 #10 「Drawerメニューを作ろう」
動画URL
Firestoreでリアルタイムに表示を更新しよう【日本一わかりやすいReact-Redux講座 実践編#11】
要点
- Firestore の
onSnapshot
メソッドを用いることで、DBの変更を自動検知するリスナー機能を作ることができる。
完成系イメージ
サイズテーブル内の「ショッピングカートアイコン」をクリックすると、該当する商品情報が Cloud Firestore に送信され、usersコレクションのサブコレクションにあたるcartサブコレクション
に保存されます。
Firebaseコンソールから確認すると、認証ユーザーのサブコレクションとして、対象商品の商品情報がcartサブコレクション
に保存されています。
加えて、Cloud Firestore 側と クライアント(React)側でリアルタイムな同期がなされるよう実装されています。これによりcartサブコレクション
の追加・変更をクライアンド側が即時検知できるため、「ショッピングカートアイコン」をクリックした瞬間に、ヘッダー内のカートアイコンのバッチの値が+1されています。
メイン
cartサブコレクションの設計
カート情報を扱うにあたり、「どの商品、どのサイズをカートに入れたか」という情報を保存する場所(コレクション)を DB(Cloud Firestore) に用意する必要があります。
カート情報はユーザー一人一人に紐づくデータと考えられるので、**usersコレクションのサブコレクションとして、cartサブコレクション
**を用意します。
言い換えると、cartサブコレクション
は、productsコレクション
とは完全に独立したコレクションとして存在することなるため、注意が必要です。
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される、という流れです。
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>
へ渡しています。
.
.
.
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()
を設定します。
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を用いたリスナー定義を一般化すると以下のようなイメージ。
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]
が更新されます。
const initialState = {
products: {
list: []
},
users: {
cart: [], //追記
isSignedIn: false,
role: "",
uid: "",
username: ""
}
};
export default initialState
-
users[:cart]
を追加します。ここに、カート情報が配列として入ります。
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に反映することが目的のため、そのままアクションへ渡しています。
export const FETCH_PRODUCTS_IN_CART = "FETCH_PRODUCTS_IN_CART";
export const fetchProductsInCartAction = (products) => {
return {
type: "FETCH_PRODUCTS_IN_CART",
payload: products
}
};
.
.
.
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
}
}
import { createSelector } from "reselect";
const usersSelector = (state) => state.users;
.
.
.
export const getProductsInCart = createSelector(
[usersSelector],
state => state.cart
)
.
.
.
-
users[:cart]
用のアクション、リデューサー、セレクターを定義します。
### 動作確認
全体の動作は「完成系イメージ」の通りです。
ここでは別途、onSnapShot
によるDBのリスナーが機能しているか、確認します。<HeadeMenu>
のリスナー関数内部にconsole.log()
を入れてみます。
snapshots.docChanges().forEach(change => {
const product = change.doc.data();
const changeType = change.type
console.log({商品名: product.name, サイズ: product.size, changeType: changeType}) //追記
初期レンダー & 新規作成
ブラウザを再読み込みし、<HeadeMenu>
を再レンダーすると、すでにDB内に保存されていたドキュメントが、全てadded
として処理されています。
また、実際にサイズテーブル内のカートアイコンをクリックすると、DBが更新されると同時に該当商品のドキュメントがadded
属性を持ってリスナーで検知されています(そしてStoreへ反映され、ヘッダーのカートアイコンバッジが+1される、という流れです)
修正
カート内部の情報をアプリ側から修正する機能は実装していないので、Firebaseコンソールから直接、修正操作を加えています。modified
が検知されています。
削除
removed
が検知されています。
さいごに
今回の要点をおさらいすると、
- Firestore の
onSnapshot
メソッドを用いることで、DBの変更を自動検知するリスナー機能を作ることができる。
以上です!onSnapshot を用いれば、SlackやLINEを模した「リアルタイムチャットアプリ」などを簡単に作れそうですね
このような学習内容を日々呟いていますので、よろしければTwitter(@ddpmntcpbr)のフォローもよろしくお願いします。