【備忘録】日本一わかりやすいReact-Redux講座 実践編 #7 「商品を一覧表示しよう」

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

前回まで講座で、Products に対する CRUD のうち、C(:Create), Update(:Update)を実装しました。今回の講座では、CRUD の 一覧表示 ``R(:Read)``、及び ``D(:Delete)``を実装します。


※ 前回記事: [【備忘録】日本一わかりやすいReact-Redux講座 実践編 #6 「useEffectで編集機能を作ろう」](https://qiita.com/ddpmntcpbr/items/ffbfaebd948ac5c6ae4f)

## 要点
- Reactでは、``親コンポーネントで子コンポーネントをイテレート``する形でリスト一覧表示を実装する。
- Material-UI における ``<Card/>`` で、画像＋文字列のコンポーネントを実装する。
- Material-UIにおける ``<Menu/>`` で、モーダル開閉を含めたメニューバーを実装する。

## 完成系イメージ

**``http://localhost:3000/``**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/3605500b-b2ff-9e7b-398f-0831ed7f147c.png)


いままで殺風景だったルートページ("/")に、Products を一覧表示します。各Productの登録画像のうち一つがサムネイルとなって表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/a3f405a9-d116-e868-9ce4-1e08807516ed.png)

メニューを開くと「編集する」「削除する」ボタンが表示されます、。「編集する」をクリックすると、各Productsの編集ページ("/products/edit/:id")へ遷移します。

「削除する」をクリックすると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/d91b1852-3dcc-12e4-c9d3-f1f822f52c0f.png)

Productが削除され、残りのProductsで一覧表示がされます。

##  #7「商品を一覧表示しよう」

### reducksファイル

Productsの一覧リスト表示を実装するにあたり、まずreducksファイルを定義します。

``operations.js``内でDBから Products を一括で取得する関数(``fetchProducts()``)を定義し、取得結果を Store に保存するよう、``actions.js``、``reducers.js``に追記していきます。

また、Store内のProductsをビュー側で参照できるセレクターを``selectors.js``に定義していきます。

```:実装ファイル(reducks)
1.src/reducks/products/operations.js
2.src/reducks/products/actions.js
3.src/reducks/products/reducers.js
4.src/reducks/products/selectors.js
```


```javascript:1.src/reducks/products/operations.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``へ渡します。

```javascript:2.src/reducks/products/actions.js
export const FETCH_PRODUCTS = "FETCH_PRODUCTS";
export const fetchProductsAction = (products) => {
  return {
    type: "FETCH_PRODUCTS",
    payload: products
  }
}
```

```javascript:3.src/reducks/products/reducers.js
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 側で検知できるようにしています。

```javascript:4.src/reducks/products/selectors.js
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``として、リスト表示を実装します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/72291f30-7e76-2415-65ff-65e508bc22bc.png)


```:実装ファイル(ビュー関連)
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
```

```react:1.src/Router.jsx
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``テンプレートに設定します。

```react:2.src/templates/ProductList.jsx
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``に設定します。


```javascript:3.src/templates/index.js
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``をエントリーポイントに追加。

```react:4.src/components/Products/ProductCard.jsx
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コンポーネントを多く使用しているため、補足します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/bb07469c-720a-8fc1-b7cb-ae7dba8b07a8.png)

``<Card>``コンポーネントで、各リストアイテムを表示しています。画像(``<CardMedia />``)、文字列(``<CardContent />``)の配置、及びサイズは任意に調整可能です。

``<IconButton />``をクリックすることで``handleClick()``が発火し、``setAnchorEl()``で``anchirEl``に値がセットされることで、モーダルが開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/49c1bcf3-7ff7-209f-a974-1b961ff5f126.png)

「編集する」をクリックすると``dispatch(deleteProduct(props.id))``が発火し、該当するProductsの編集ページへ遷移します。

「削除する」をクリックすると、``dispatch(deleteProduct(props.id))``が発火し、該当するProductsの削除処理が行われます(``deleteProduct()``はこのあと実装)

```javascript:5.src/components/Products/index.js
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
```

```javascript:1.src/reducks/products/operations.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 ()``に渡します。

```javascript:2.src/reducks/products/actions.js
export const DELETE_PRODUCT = "DELETE_PRODUCT";
export const deleteProductAction = (products) => {
  return {
    type: "DELETE_PRODUCT",
    payload: products
  }
}
.
.
.
```

```javascript:3.src/reducks/products/reducers.js
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上でも商品情報の削除が行われているだけ、確認します。、


**``http://localhost:3000/``**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/7e6a007e-9d5a-b904-9ca4-1653dd717f84.png)

**``Firebaseコンソール -> Database``**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/26c2ed97-a815-964b-6886-4f3a6f9377c4.png)

productsが4つ保存されています。メニューから「削除する」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/ab833024-9401-de08-66fb-7d637b1db9ec.png)

↓

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/05d88cd7-43c9-0280-bb2f-2d8fe31b488a.png)

商品情報が表示されなくなりました。Firebaseコンソールを確認すると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/0d3afd62-d1b8-860c-6865-597b34631824.png)

確かに商品情報が削除されています！

## さいごに

今回の要点を整理すると、

- Reactでは、``親コンポーネントで子コンポーネントをイテレート``する形でリスト一覧表示を実装する。
- Material-UI における ``<Card/>`` で、画像＋文字列のコンポーネントを実装する。
- Material-UIにおける ``<Menu/>`` で、モーダル開閉を含めたメニューバーを実装する。

今回は以上です！

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