【備忘録】日本一わかりやすいReact-Redux講座 実践編 #6 「useEffectで編集機能を作ろう」

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

前回講座では、画像ファイルを商品情報(Products)として登録できるようになりました。(CRUDの**C**)

今回の講座では、一度登録した商品情報を編集・更新できるようにします(CRUDの**U**)

※ 前回記事: [【備忘録】日本一わかりやすいReact-Redux講座 実践編 #5 「Cloud Storageに画像をアップ&プレビュー&削除」](https://qiita.com/ddpmntcpbr/items/098e9d23f2501c3c6d78)

## 要点
- ルーティングの正規表現を用いることで、各 products ドキュメントに対応した編集画面を表示させる
- Cloud Firestore からのデータの取得時には``useEffect``を用いる。


## #6_useEffectで編集機能を作ろう

今回の実装機能は以下の二つです。
- Products の編集機能
- Products 内にサイズ、数量のフィールドを追加

『[第1回講座](https://qiita.com/ddpmntcpbr/items/c6e7503e0c88e363d56d)』で示した通り、今回のアプリでは、商品情報のDBについては、 CRUDを全て実装します。

> [リンク先引用]
> **商品情報のCRUD**
> - Create: 商品情報の追加
> - Read: 商品情報の読み込み
> - Update: 商品情報の更新
> - Delete: 商品情報の削除

前回講座までで、Productsの新規登録(**Create**)は、以下の仕様で実装済みでした。

今回の講座では、CRUDのうち、**Update**を実装します。

加えて、Productsが持つ商品情報として、新たに「サイズ(S, M, Lなど)」と「数量」の情報を保存できるようにします。

### 完成系イメージ

**``http://localhost:3000/product/edit``**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/b72133ad-6aa2-9d5c-e80e-ea4c5bae95e1.png)

商品情報登録画面の下部に、サイズと数量を登録するエリアを追加します。

サイズ、数量を入力して右下のチェックマークをクリックすると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/aab8bf84-6f1a-54d1-6a22-ee2525648ef7.png)

↓

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/acb905be-ae19-9d7c-6e71-49a8471f210e.png)

サイズと、それに紐づく数量の情報が入力されます。

これは複数記入することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/7595f7a3-8209-b6db-67c3-8b42909b0ee7.png)

横の鉛筆マーク、ゴミ箱マークをクリックすることで、編集・削除が行えます。

最終的に入力したサイズ、数量の情報は、その他商品情報と合わせて、Cloud Firestore に登録されます。

続いて、編集機能を見ます。Firebaseコンソール -> Database から、保存済みの任意の products の idをコピーを取得し、URLの末尾にコピーします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/c9bf9f49-19d2-84fa-f997-aa731787fc38.png)

**``http://localhost:3000/product/edit/aaIQN0IrCFXjQkZRnc8W``**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/3f8167e9-7442-b015-8660-d1d683d8d5f5.png)

Cloud Firestore 上のデータが画面に反映され、各入力フィールドに表示されています。

入力値を変更し「商品情報を保存」をクリックすると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/e4d23c20-8cd4-3eb8-348e-97e9e0ce417e.png)

↓

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/c1eb0e05-e75b-286e-d8d7-bb494b9fc7d8.png)

商品情報の Update ができました！

### 商品情報登録画面のルーティング修正

前回までで、Products の新規登録機能を、下記の仕様で実装しました。

- テンプレート: ``ProductEdit.jsx``
- ルーティング: ``/product/edit`` 

Updateを実装するにあたり、テンプレート、ルーティングを新規登録と別個に実装してもよいのですが、今回は**ルーティングの正規表現**をうまく使うことで、テンプレートを共通して使用します。

#### ルーティングの正規表現

src/Roter.jsx を編集します。

```react:src/Router.jsx
import React from 'react';
import {Route, Switch} from "react-router";
import {Home,ProductEdit,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={Home} />
        {/* <Route exact path={"/product/edit"} component={ProductEdit} /> */}
        <Route path={"/product/edit(/:id)?"} component={ProductEdit} /> //追記
      </Auth>
    </Switch>
  );
};

export default Router
```

``path={"/product/edit(/:id)?"}``の部分が、正規表現を用いたルーティングです。

``exact path`` -> ``path``とすることで、ルーティングの条件が**完全一致**から**部分一致**に切り替わります。

加えて、正規表現である``(/:id)?``を付け加えることで、

- /product/edit
- /product/edit/(何らかの文字列)

にアクセスがあったときは、いずれも``component={ProductEdit}``が描画されるようになります。

この``:id``の部分には、**Cloud firestore 上の Products の id**が文字列として入ります。これをアプリ側で取得することで、「どの products を編集したいか」を判断します。

最終的には、

- /product/edit
→ 新規登録画面

- /product/edit/(保存済みの products の id)
→ 編集画面

のように切り替えます。特に編集画面の方では、対応する id の DB情報を取得し、アプリ側で表示させる処理(各入力フォームへの反映)を行います。

### SetSizeAreaコンポーネント

編集機能の前に、サイズ・数量を入力するフォームを作成します。

今回は``ProductEdit.jsx``に直接記述するのではなく、``SetSizeArea.jsx``という外部コンポーネントを定義して実装します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/4419fe8f-7855-1854-cff1-3583eda7ee9b.png)


```react:src/components/products/SetSizeArea.jsx
import React, {useCallback, useMemo, useState} from 'react';
import {TextInput} from "../UIkit";
import IconButton from "@material-ui/core/IconButton";
import CheckCircleIcon from '@material-ui/icons/CheckCircle';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Paper from '@material-ui/core/Paper';
import {makeStyles} from "@material-ui/styles";

const useStyles = makeStyles({
  checkIcon: {
    float: "right"
  },
  iconCell: {
    height: 48,
    width: 48
  }
})

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

  const [index, setIndex] = useState(0),
        [size, setSize] = useState(""),
        [quantity, setQuantity] = useState(0)

  const inputSize = useCallback((event)=>{
    setSize(event.target.value)
  },[setSize]);

  const inputQuantity = useCallback((event)=>{
    setQuantity(event.target.value)
  },[setQuantity]);

  const addSize = (index, size, quantity) => {
    if (size === "" || quantity === "" ) {
      return false
    } else {
      if (index === props.sizes.length) {
        props.setSizes(prevState => [...prevState, {size:size, quantity:quantity}])
        setIndex(index + 1)
        setSize("")
        setQuantity(0)
      } else {
        const newSizes = props.sizes
        newSizes[index] = {size:size, quantity:quantity}
        props.setSizes(newSizes)
        setIndex(newSizes.length)
        setSize("")
        setQuantity(0)
      }
    }
  };

  const editSize = (index,size,quantity) => {
    setIndex(index);
    setSize(size);
    setQuantity(quantity);
  }

  const deleteSize = (deleteIndex) => {
    const newSizes = props.sizes.filter((item,i) => i !== deleteIndex);
    props.setSizes(newSizes);
  }

  const memoIndex = useMemo(() => {
    setIndex(props.sizes.length)
  }, [props.sizes.length])

  return (
    <div>
      <TableContainer component={Paper}>
        <Table>
          <TableHead>
            <TableRow>
              <TableCell>サイズ</TableCell>
              <TableCell>数量</TableCell>
              <TableCell className={classes.iconCell} />
              <TableCell className={classes.iconCell} />
            </TableRow>
          </TableHead>
          <TableBody >
            { props.sizes.length > 0 && (
              props.sizes.map((item,i) => (
                <TableRow key={item.size}>
                  <TableCell>{item.size}</TableCell>
                  <TableCell>{item.quantity}</TableCell>
                  <TableCell>
                    <IconButton className={classes.iconCell} onClick={() => editSize(i,item.size,item.quantity)}>
                      <EditIcon />
                    </IconButton>
                  </TableCell>
                  <TableCell>
                    <IconButton className={classes.iconCell} onClick={() => deleteSize(i)}>
                      <DeleteIcon />
                    </IconButton>
                  </TableCell>
                </TableRow>
              ))
            )}
          </TableBody>
        </Table>
        <div>
          <TextInput
            fullWidth={false} label={"サイズ"} multiline={false} requires={true}
            onChange={inputSize} rows={1} value={size} type={"text"}
          />
          <TextInput
            fullWidth={false} label={"サイズ"} multiline={false} requires={true}
            onChange={inputQuantity} rows={1} value={quantity} type={"number"}
          />
        </div>
        <IconButton className={classes.checkIcon} onClick={() => addSize(index, size,quantity)}>
          <CheckCircleIcon/>
        </IconButton>
      </TableContainer>
    </div>
  )
}

export default SetSizeArea
```

JSX部分については、 Material-UI の Table をうまく活用して実装しています。

本コンポーネントは、親コンポーネントである``ProductEdit.jsx``から、``sizes``(およびそれを変更する``setSizes``)を受け取ります。

``sizes``の中には、``size``(サイズ)と``quantitiy``(数量)の組み合わせが、複数格納されています。

これらの値は、最終的には他の商品情報と合わせて Cloud Firestore へ送信されるため、``SetSizeArea.jsx``のローカル state としてではなく、``ProducEdit``内で定義し、propsとして渡しています。

``SetSizeArea.jsx``では、受け取った``props.sizes``を扱うために以下の3つのローカル stateを 定義しています。

1. index(int): ``props.sizes.length``(登録されたサイズ・数量セットの数)を格納する
2. size(str): フォームに入力された「サイズ」を格納する
3. quantity(int): フォームに入力された「数量」を格納する

今回のアプリでは、サイズ・数量のセットは複数登録できる仕様にします。「今現在、何セット登録されているか」を表す情報が都度必要になるため、``index``を定義しています。

各 state の役割を整理すると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/cee85363-139e-9256-025c-f1e8ebda8a3a.png)

この画面の状態では、``index=2``となります。

state の操作として、以下の3つの関数を定義してます。

1. addSize: 入力したサイズ・数量を登録する
2. editSize: 登録したサイズ・数量を編集する
3. deleteSize: 登録したサイズ・数量を削除する

addSize では、``props.setSize``を用いて、size と quantity を``props.sizes``に追加します。この操作により、index が +1 されます。

editSize　では、該当する index の``props.sizes``の値を、入力フォームの値をもとに更新します。この関数は、index の値は変わりません。

deleteSize では、該当する index の``props.sizes``を削除します。index の値は -1 されます。

関数と画面のアイコンを紐づけるとこんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/45ede86c-c59f-c684-7ab7-0cd994782326.png)

ついでに、``src/components/products``直下のコンポーネントを外部読み込みするためのエントリーポイントを作成します(今まではエントリーポイント経由ではなく、直接読み込んでいました)

```javascript:src/components/products/index.js
export {default as ImageArea} from "./ImageArea"
export {default as ImagePreview} from "./ImagePreview"
export {default as SetSizeArea} from "./SetSizeArea"
```

### 編集機能の追加

先ほどの``SetSizeArea.jsx``の配置と、編集機能の追加をいっぺんに行います。編集ファイルは以下の通り。

```
1. src/templates/ProductEdit.jsx
2. src/reducks/products/operations.js
```

```react:src/templates/ProductEdit.jsx
import React,{ useState, useCallback, useEffect } from "react";
import { PrimaryButton, SelectBox, TextInput } from "../components/UIkit";
import { useDispatch } from "react-redux";
import { saveProduct } from "../reducks/products/operations";
import {db} from "../firebase"
import { ImageArea,SetSizeArea } from "../components/Products";

const ProductEdit = () => {
  const dispatch = useDispatch();

  let id = window.location.pathname.split('/product/edit')[1];
  if (id !== "") {
    id = id.split("/")[1]
  }

  const [name, setName] = useState(""),
        [description, setDescription] = useState(""),
        [category, setCategory] = useState(""),
        [gender, setGender] = useState(""),
        [images, setImages] = useState([]),
        [price, setPrice] = useState(""),
        [sizes, setSizes] = useState([]);

  const inputName = useCallback((event) => {
    setName(event.target.value)
  },[setName])

  const inputDescription = useCallback((event) => {
    setDescription(event.target.value)
  },[setDescription])

  const inputPrice = useCallback((event) => {
    setPrice(event.target.value)
  },[setPrice])

  const categories = [
    {id:"tops", name:"トップス"},
    {id:"shirt", name:"シャツ"},
    {id:"pants", name:"パンツ"}
  ]

  const genders = [
    {id:"all", name:"すべて"},
    {id:"male", name:"メンズ"},
    {id:"female", name:"レディース"}
  ]

  useEffect(() => {
    if (id !== "") {
      db.collection("products").doc(id).get()
        .then(snapshot => {
          const data = snapshot.data();
          setImages(data.images);
          setName(data.name);
          setDescription(data.description)
          setGender(data.gender);
          setCategory(data.category);
          setPrice(data.price)
          setSizes(data.sizes)
        })
    }
  },[id]);

  return (
    <section>
      <h2 className="u-text__headline u-text-center">商品の登録・編集</h2>
      <div className="c-section-container">
        <ImageArea images={images} setImages={setImages} />
        <TextInput
          fullWidth={true} label={"商品名"} multiline={false} required={true}
          onChange={inputName} rows={1} value={name} type={"text"}
          />
        <TextInput
          fullWidth={true} label={"商品説明"} multiline={true} required={true}
          onChange={inputDescription} rows={5} value={description} type={"text"}
          />
        <SelectBox
          label={"カテゴリー"} required={true} options={categories} select={setCategory} value={category}
        />
        <SelectBox
          label={"性別"} required={true} options={genders} select={setGender} value={gender}
        />
        <TextInput
          fullWidth={true} label={"価格"} multiline={false} required={true}
          onChange={inputPrice} rows={1} value={price} type={"number"}
          />
        <div className="module-spacer--small" />
        <SetSizeArea sizes={sizes} setSizes={setSizes}  />
        <div className="module-spacer--small" />
        <div className="center">
          <PrimaryButton
            label={"商品情報を保存"}
            onClick={() => dispatch(saveProduct(id, name, description, category, gender, images, price, sizes))}
            />
        </div>
      </div>
    </section>
  )
}

export default ProductEdit
```

``let id = ``の箇所で、URL末尾の``:id``にあたる部分の文字列を抽出しています。これが Cloud Firestore の productsドキュメントの id に対応します。

id に対応する Cloud Firestore の productsドキュメントを取得しています。もし、``id===""``であるときは、新規登録画面として描画するため、この操作は行いません。

このCloud Firestore からのデータの取得は、``useEffect()``を用いて実装しています。第2引数に``[id]``を入れることで、``[id]``が切り替わるタイミングでのみ、Cloud Firestore との通信が行われるようにしています。

そして、取得した products ドキュメントの各フィールド値を、``<TextInput>``の value に渡すことで、入力フォームに対して「現在の登録内容」を表示させます。

このように実装することで、``ProductEdit.jsx``が、「新規登録」「更新」両方の場面で共通して使えるテンプレートになります。

続いて、「商品情報を登録する」ボタンで発火する``saveProduct``を編集します。現在は、「新規登録」にしか対応していないので、こちらも「更新」に対応できるようにします。

```javascript:src/reduck/products/operations.js
import {db, FirebaseTimestamp} from "../../firebase";
import { push } from "connected-react-router";

const productsRef = db.collection("products")

export const saveProduct = (id,name,description,category,gender,images,price) => {
  return async (dispatch) => {
    const timestamp = FirebaseTimestamp.now()

    const data = {
      category: category,
      description: description,
      gender: gender,
      images: images,
      name: name,
      price: parseInt(price, 10),
      updated_at: timestamp
    }

    if (id === "") {
      const ref = productsRef.doc();
      id = ref.id;
      data.id = id
      data.created_at = timestamp
    }

    return productsRef.doc(id).set(data, {merge: true})
      .then(() => {
        dispatch(push('/'))
      }).catch((error) => {
        throw new Error(error)
      })
  }
}
```

新規idの発行は、「新規登録」のときのみとしたいので、``if (id === "") {...``で条件分岐させています。

また、最後のreturnで``.set(data, {merge: true})``とすることで、　既存のフィールド値はそのまま変更箇所のみを更新する、という操作が可能になります。

### 動作確認

まずは新規登録を通じて、``SetSizeArea.jsx``の動作を確認します。

**``http://localhost:3000/product/edit``**

![6-4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/66c7fc3c-c611-293e-77d0-8b1615b9d40b.gif)

登録、編集、削除が行えています!

その他フォームも入力し、「商品情報を保存」します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/79c07893-1087-a55b-d1fb-42773fff2aa1.png)

実装が正しく行えていれば、アプリはルートにリダイレクトされます。

Firebaseコンソールから確認すると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/c574e736-6d97-3d88-c4d5-5f2e500af392.png)

ちゃんと sizes の情報も登録されていますね！

続いて、編集機能を確認します。先ほどの登録した商品情報のidをコピーし、該当URLにアクセスします。

**``http://localhost:3000/product/edit/edazR9xyDgOhevYrDpAD``**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/c298827f-825f-ce5b-c5bb-79d2551aa312.png)

Cloud Firestore に一度保存された情報が、入力フォームに反映されています！

値を更新して、再度データを送信します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/f9109782-ba09-8e9d-1e8a-a84b072fa64c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/13debb43-b736-b84a-e969-79c50af42440.png)

無事、Update機能が動作しています！

### さいごに
今回の要点を整理すると、
- ルーティングの正規表現を用いることで、各 products ドキュメントに対応した編集画面を表示させる
- Cloud Firestore からのデータの取得時には``useEffect``を用いる。

今回は以上です！

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