はじめに
この記事は、トラハック氏(@torahack_)が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。
前回まで講座で、Products に対する CRUD のうち、C(:Create), Update(:Update)を実装しました。今回の講座では、CRUD の 一覧表示 R(:Read)
、及び D(:Delete)
を実装します。
※ 前回記事: 【備忘録】日本一わかりやすいReact-Redux講座 実践編 #6 「useEffectで編集機能を作ろう」
要点
- Reactでは、
親コンポーネントで子コンポーネントをイテレート
する形でリスト一覧表示を実装する。 - Material-UI における
<Card/>
で、画像+文字列のコンポーネントを実装する。 - Material-UIにおける
<Menu/>
で、モーダル開閉を含めたメニューバーを実装する。
完成系イメージ
http://localhost:3000/
いままで殺風景だったルートページ("/")に、Products を一覧表示します。各Productの登録画像のうち一つがサムネイルとなって表示されます。
メニューを開くと「編集する」「削除する」ボタンが表示されます、。「編集する」をクリックすると、各Productsの編集ページ("/products/edit/:id")へ遷移します。
「削除する」をクリックすると、
Productが削除され、残りのProductsで一覧表示がされます。
#7「商品を一覧表示しよう」
reducksファイル
Productsの一覧リスト表示を実装するにあたり、まずreducksファイルを定義します。
operations.js
内でDBから Products を一括で取得する関数(fetchProducts()
)を定義し、取得結果を Store に保存するよう、actions.js
、reducers.js
に追記していきます。
また、Store内のProductsをビュー側で参照できるセレクターをselectors.js
に定義していきます。
1.src/reducks/products/operations.js
2.src/reducks/products/actions.js
3.src/reducks/products/reducers.js
4.src/reducks/products/selectors.js
import { fetchProductsAction } from "./actions";
.
.
.
const productsRef = db.collection("products")
.
.
.
export const fetchProducts = () => {
return async (dispatch) => {
productsRef.orderBy("updated_at","desc").get()
.then(snapshots => {
const productList = []
snapshots.forEach(snapshot => {
const product = snapshot.data();
productList.push(product)
})
dispatch(fetchProductsAction(productList));
})
}
}
-
orderBy("updated_at","desc")
で、DBから取得した結果を更新日時に対する昇順に整理し、fetchProductsAction
へ渡します。
export const FETCH_PRODUCTS = "FETCH_PRODUCTS";
export const fetchProductsAction = (products) => {
return {
type: "FETCH_PRODUCTS",
payload: products
}
}
import * as Actions from './actions'
import initialState from '../store/initialState'
export const ProductsReducer = (state = initialState.products, action) => {
switch (action.type) {
case Actions.FETCH_PRODUCTS:
return {
...state,
list: [...action.payload]
};
default:
return state
}
}
-
list: ...action.payload
ではなく、list: [...action.payload]
とのように、新しい配列として定義することで、配列の格納先メモリが切り替わり、 state が変更されたことを Component 側で検知できるようにしています。
import {createSelector} from "reselect";
const productsSelector = (state) => state.products;
export const getProducts = createSelector(
[productsSelector],
state => state.list
)
-
getProducts()
で、Store内の Products を Component 側から参照できるようにします。
ビューファイル
続いてビューファイルを作成します。
Reactにおいて、「DBの中身をリストで一覧表示」を実装するときは、親コンポーネントで子コンポーネントをイテレートするという実装が一般的です。
今回は、親:ProductList.jsx
、子:ProductCard.jsx
として、リスト表示を実装します。
1.src/Router.jsx
2.src/templates/ProductList.jsx
3.src/templates/index.js
4.src/components/Products/ProductCard.jsx
5.src/components/Products/index.js
import React from 'react';
import {Route, Switch} from "react-router";
import {ProductEdit,ProductList,Reset,SignIn,SignUp} from "./templates";
import Auth from "./Auth"
const Router = () => {
return (
<Switch>
<Route exact path={"/signup"} component={SignUp} />
<Route exact path={"/signin"} component={SignIn} />
<Route exact path={"/signin/reset"} component={Reset} />
<Auth>
<Route exact path={"(/)?"} component={ProductList} /> {/* 修正 */}
<Route path={"/product/edit(/:id)?"} component={ProductEdit} />
</Auth>
</Switch>
);
};
export default Router
ルートページ("/")をProductList
テンプレートに設定します。
import React,{useEffect} from "react";
import {ProductCard} from "../components/Products";
import {useDispatch, useSelector} from "react-redux";
import {fetchProducts} from "../reducks/products/operations"
import {getProducts} from "../reducks/products/selectors"
const ProductList = () => {
const dispatch = useDispatch();
const selector = useSelector((state) => state);
const products = getProducts(selector);
useEffect (() => {
dispatch(fetchProducts())
},[]);
return (
<section className="c-section-wrapin">
<div className="p-grid__row">
{products.length > 0 &&(
products.map(product => (
<ProductCard
key={product.id} id={product.id} name={product.name}
images={product.images} price={product.price}
/>
))
)}
</div>
</section>
)
}
export default ProductList
-
useEffect()
により、初期レンダー時において、先ほどoperations.js
で定義したfetchProducts()
を実行します。その後、const products = getProducts(selector);
でDBから取得した商品情報がproducts
に格納されます。 -
products.length > 0 &&...
で、products
が1つ以上存在する場合のみ、以下の要素をレンダーするよう条件分岐させます。 -
products.map(product => (...))
で、mapメソッドを用いて配列の中身をイテレートし、子コンポーネントの<ProductCard />
に渡します。イテレートする際は、子コンポーネントには一意の値をkey
として渡す必要があるため、ユニークな値であるproduct.id
をkey
に設定します。
export {default as Home} from './Home'
export {default as ProductEdit} from './ProductEdit'
export {default as ProductList} from './ProductList' //追記
export {default as Reset} from './Reset'
export {default as SignIn} from './SignIn'
export {default as SignUp} from './SignUp'
-
ProductList
をエントリーポイントに追加。
import React, {useState} from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardMedia from '@material-ui/core/CardMedia';
import Typography from '@material-ui/core/Typography';
import NoImage from "../../assets/img/src/no_image.png";
import {push} from "connected-react-router";
import {useDispatch} from "react-redux";
import IconButton from "@material-ui/core/IconButton";
import Menu from "@material-ui/core/Menu";
import MenuItem from "@material-ui/core/MenuItem";
import MoreVertIcon from '@material-ui/icons/MoreVert';
import {deleteProduct} from "../../reducks/products/operations";
const useStyles = makeStyles((theme) => ({
root: {
[theme.breakpoints.down("sm")]: {
margin: 8,
width: "calc(50% - 16px)"
},
[theme.breakpoints.up("sm")]: {
margin: 16,
width: "calc(33.333% - 32px)"
}
},
content: {
display: "flex",
padding: "16 8",
textAlign: "left",
"&:last-child": {
paddingBottom: 16
}
},
media: {
height: 0,
paddingTop: "100%"
},
price: {
color: theme.palette.secondary.main,
fontSize: 16
}
}));
const ProductCard = (props) => {
const classes = useStyles();
const dispatch = useDispatch();
const [anchorEl, setAnchorEl] = useState(null);
const handleClick = (event) => {
setAnchorEl(event.currentTarget)
};
const handleClose = () => {
setAnchorEl(null)
};
const images = (props.images.length > 0) ? props.images : [{path: NoImage}];
const price = props.price.toLocaleString();
return (
<Card className={classes.root}>
<CardMedia
className={classes.media}
image={images[0].path}
title=""
onClick={() => dispatch(push("/product/" + props.id))}
/>
<CardContent className={classes.content}>
<div onClick={() => dispatch(push("/product/" + props.id))}>
<Typography color="textSecondary" component="p">
{props.name}
</Typography>
<Typography className={classes.price} component="p">
¥{price}
</Typography>
</div>
<IconButton onClick={handleClick}>
<MoreVertIcon/>
</IconButton>
<Menu
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem
onClick={() => {
dispatch(push("/product/edit/" + props.id))
handleClose()
}}
>
編集する
</MenuItem>
<MenuItem
onClick={() => {
dispatch(deleteProduct(props.id))
handleClose()
}}
>
削除する
</MenuItem>
</Menu>
</CardContent>
</Card>
)
}
export default ProductCard
Materila-UIコンポーネントを多く使用しているため、補足します。
<Card>
コンポーネントで、各リストアイテムを表示しています。画像(<CardMedia />
)、文字列(<CardContent />
)の配置、及びサイズは任意に調整可能です。
<IconButton />
をクリックすることでhandleClick()
が発火し、setAnchorEl()
でanchirEl
に値がセットされることで、モーダルが開きます。
「編集する」をクリックするとdispatch(deleteProduct(props.id))
が発火し、該当するProductsの編集ページへ遷移します。
「削除する」をクリックすると、dispatch(deleteProduct(props.id))
が発火し、該当するProductsの削除処理が行われます(deleteProduct()
はこのあと実装)
export {default as ImageArea} from "./ImageArea"
export {default as ImagePreview} from "./ImagePreview"
export {default as ProductCard} from "./ProductCard" //追記
export {default as SetSizeArea} from "./SetSizeArea"
-
ProductCard
コンポーネントをエントリーポイントに追加。
ここまでで、一覧表示は一通り完成しました!
最後に、Productsに対するCRUDのDとして、deleteProduct()
を実装します。
商品を削除しよう
削除の実装は簡単です。reduckcパターンに則り、以下のファイルに削除処理を定義していきます。
1.src/reducks/products/operations.js
2.src/reducks/products/actions.js
3.src/reducks/products/reducers.js
import { fetchProductsAction,deleteProductAction } from "./actions";
.
.
.
const productsRef = db.collection("products")
export const deleteProduct = (id) => {
return async(dispatch, getState) => {
productsRef.doc(id).delete()
.then(() => {
const prevProducts = getState().products.list;
const nextProducts = prevProducts.filter(product => product.id !== id)
dispatch(deleteProductAction(nextProducts))
})
}
}
.
.
.
-
productsRef.doc(id).delete()
とすることで、該当するid
の商品情報を、Cloud Firestore上から削除します。 -
getState()
により、現時点でのprosuctsを取得します。その後、filter
メソッドで該当する商品情報を配列から削除し、deleteProductAction ()
に渡します。
export const DELETE_PRODUCT = "DELETE_PRODUCT";
export const deleteProductAction = (products) => {
return {
type: "DELETE_PRODUCT",
payload: products
}
}
.
.
.
import * as Actions from './actions'
import initialState from '../store/initialState'
export const ProductsReducer = (state = initialState.products, action) => {
switch (action.type) {
// 追記
case Actions.DELETE_PRODUCT:
return {
...state,
list: [...action.payload]
};
// 追記ここまで
case Actions.FETCH_PRODUCTS:
return {
...state,
list: [...action.payload]
};
default:
return state
}
}
これでProductCard.jsx
内で記述したdeleteProducts()
関数が正常に動作するようになり、削除機能が実装されます。
動作確認
動作自体は完成系イメージと同様のため割愛します。
deleteProducts()
により、Cloud Firestore上でも商品情報の削除が行われているだけ、確認します。、
productsが4つ保存されています。メニューから「削除する」をクリックします。
↓
商品情報が表示されなくなりました。Firebaseコンソールを確認すると、
確かに商品情報が削除されています!
さいごに
今回の要点を整理すると、
- Reactでは、
親コンポーネントで子コンポーネントをイテレート
する形でリスト一覧表示を実装する。 - Material-UI における
<Card/>
で、画像+文字列のコンポーネントを実装する。 - Material-UIにおける
<Menu/>
で、モーダル開閉を含めたメニューバーを実装する。
今回は以上です!
このような学習内容を日々呟いていますので、よろしければTwitter(@ddpmntcpbr)のフォローもよろしくお願いします。