新：React + FirestoreでCRUD（基礎編）


[別途CRUDの記事](https://qiita.com/zaburo/items/da787c282f5178a5f68e)を書いたら「複雑すぎる」ということなので簡単バージョンを書きます。
radio, checkbox, file等、各種Form要素のコントロールを知りたい人は、[複雑？バージョン](https://qiita.com/zaburo/items/da787c282f5178a5f68e)をご覧下さい。


## 仕様

下記のような感じ。
CRUDとはいえ、React（SAP）なので、詳細表示、編集、削除は1つのページで実装します。

![スクリーンショット 2019-12-11 9.34.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55188/7748d76c-efa5-1344-8b90-14c9c1c00fd3.png "スクリーンショット 2019-12-11 9.34.06.png")


## 準備

### create-react-app

create-react-appでプロジェクトを作成します。

```bash
create-react-app crud-basic
cd crud-basic
```

### 必要なモジュールインストール

必要なモジュールをインストールします。

```bash
npm install --save bootstrap reactstrap react-router-dom formik yup firebase
```

>yarnならyarn add に置き換えてください。

簡単にモジュールの解説をしておくと、

* bootstrap reactstrap : bootstrap風に見た目を整えるのに使います
* react-router-dom：SPA内でページ遷移（ページ移動）するのに使います
* formik yup : Formの管理とバリデーションについかいます
* firebase ： firebaseを利用するのに使います

### 必要なファイル生成

あらかじめ必要なファイルを生成しておきます。

```bash
cd src
touch Firebase.js

mkdir screens
touch screens/Index.js
touch screens/Create.js
touch screens/Detail.js
touch screens/page404.js
```

### Firebase.js

Firebaseを利用するための設定を行います。各自の環境に合わせて設定してください。

```js:Firebase.js
import firebase from 'firebase/app';
import 'firebase/firestore';

const firebaseConfig = {
    apiKey: "xxxxxxxxxx",
    authDomain: "xxxxxxxxxx",
    databaseURL: "xxxxxxxxxx",
    projectId: "xxxxxxxxxx",
    storageBucket: "xxxxxxxxxx",
    messagingSenderId: "xxxxxxxxxx",
    appId: "xxxxxxxxxx",
    measurementId: "xxxxxxxxxx"
};

firebase.initializeApp(firebaseConfig);
export default firebase;
export const db = firebase.firestore();
```

## 実装：骨組み作り

CRUD処理を実装するまえにreact-routerでページ遷移できる最低限の記述と動作チェックをしてみます。

### Index.js

ただ、「一覧表示」と表示するだけ。新規作成ページへのリンクを貼っておきます。

```js:Index.js
import React from 'react';
import { Link } from 'react-router-dom';

import firebase, { db } from '../Firebase';

class Index extends React.Component {
    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">一覧表示</h3>
                <div className="my-3"><Link to="/create">新規登録</Link></div>
            </div>
        );
    }
}

export default Index;
```

### Create.js

「新規作成」の表示とTopページへ戻るリンクを設置。

```js:Create.js
import React from 'react';
import { Link } from 'react-router-dom';

class Create extends React.Component {
    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">新規作成</h3>
                <div className="text-right my-3"><Link to="/">一覧へ戻る</Link></div>
            </div>
        );
    }
}

export default Create;
```

### Detail.js

「詳細・編集」の表示とTopページへ戻るリンクを設置。

```ja:Detail.js
import React from 'react';
import { Link } from 'react-router-dom';

class Detail extends React.Component {
    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">詳細・編集</h3>
                <div className="text-right my-3"><Link to="/">一覧へ戻る</Link></div>
            </div>
        );
    }
}

export default Detail;
```

### page404.js

404用のページも作っておきます。

```js:page404.js
import React from 'react';
import { Link } from 'react-router-dom';

class page404 extends React.Component{
    render(){
        return(
            <div className="container">
                <h3 className="text-center my-5">Page not found.</h3>
                <div className="text-center"><Link to="/">トップページへ</Link></div>
            </div>
        );
    }
}

export default page404;
```

### App.js


各ファイルができたらルーティングを設定します。


```js:App.js
import React from 'react';
import './App.css';
import { BrowserRouter, Route, Switch } from 'react-router-dom';

//screens
import Index from './screens/Index';
import Create from './screens/Create';
import Detail from './screens/Detail';
import page404 from './screens/page404';

class App extends React.Component {
  render() {
    return (
      <BrowserRouter>
        <Switch>
          <Route exact path="/" component={Index} />
          <Route path="/create/" component={Create} />
          <Route path="/detail/:uid" component={Detail} />
          <Route path="/404" component={page404} />
          <Route component={page404} />
        </Switch>
      </BrowserRouter>
    );
  }
}

export default App;
```

### 動作確認

ここまでできたら、各ページの表示やルーティングがうまくいくか確認してください。

```bash
npm start
```

>yarn start

Topページはもちろん、/create, /detail, /404等が表示されるか確認してみてください。

## 実装：本実装

ではそれぞれのページを実装していきます。

### Create.js


データがないと表示できないのでCreate.jsから実装します。
nameとemailだけを入力・登録するFormを設置します。Formikで最低限のバリデーションも付けています。

```js:Create.js
import React from 'react';
import { Form, FormGroup, Label, Input, Button, FormFeedback } from 'reactstrap';
import { Link } from 'react-router-dom';
import { Formik } from 'formik';
import * as Yup from 'yup';
import firebase, { db } from '../Firebase';

class Create extends React.Component {

    //登録ボタンが押されたら
    handleOnSubmit = (values) => {
        const docId = db.collection("members").doc().id;
        db.collection("members").doc(docId).set({
            docId: docId,
            name: values.name,
            email: values.email,
            createdAt: firebase.firestore.FieldValue.serverTimestamp(),
        });

        //登録後、Topに移動
        this.props.history.push("/");
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">新規作成</h3>
                <div className="text-right my-3"><Link to="/">一覧へ戻る</Link></div>
                <Formik
                    initialValues={{ name: '', email: '' }}
                    onSubmit={values => this.handleOnSubmit(values)}
                    validationSchema={Yup.object().shape({
                        name: Yup.string().required('氏名は必須です。'),
                        email: Yup.string().email('emailの形式ではありません。').required('Emailは必須です。'),
                    })}
                >
                    {
                        ({ handleSubmit, handleChange, handleBlur, values, errors, touched }) => (
                            <Form onSubmit={handleSubmit}>
                                <FormGroup>
                                    <Label for="name">氏名</Label>
                                    <Input
                                        type="text"
                                        name="name"
                                        id="name"
                                        value={values.name}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.name && errors.name)}
                                    />
                                    <FormFeedback>
                                        {errors.name}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup>
                                    <Label for="email">Email</Label>
                                    <Input
                                        type="email"
                                        email="email"
                                        id="email"
                                        value={values.email}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.email && errors.email)}
                                    />
                                    <FormFeedback>
                                        {errors.email}
                                    </FormFeedback>
                                </FormGroup>
                                <Button type="submit">登録</Button>
                            </Form>
                        )
                    }
                </Formik>
            </div>
        );
    }
}

export default Create;
```

### Index.js

次に登録されたデータの一覧を作成表示ページを作成します。

```js:Index.js
import React from 'react';
import { Link } from 'react-router-dom';

import firebase, { db } from '../Firebase';

class Index extends React.Component {

    state = {
        list: [],
    }

    //データ取得
    getData = async () => {
        const colRef = db.collection("members")
            .orderBy('createdAt', 'desc')
            .limit(10);
        const snapshots = await colRef.get();
        const docs = snapshots.docs.map(doc => doc.data());
        await this.setState({
            list: docs,
        });
    }

    //更新時のcalback
    onCollectionUpdate = (querySnapshot) => {
        //変更の発生源を特定 local:自分, server:他人
        // const source = querySnapshot.metadata.hasPendingWrites ? "local" : "server";
        // if (source === 'local')  this.getData(); //期待した動きをしない
        this.getData();
    }

    componentDidMount = async () => {
        //普通に取得
        await this.getData();
        //collectionの更新を監視
        this.unsubscribe = db.collection("members").onSnapshot(this.onCollectionUpdate);
    }

    //監視解除
    componentWillUnmount = () => {
        this.unsubscribe();
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">一覧表示</h3>
                <div className="my-3"><Link to="/create">新規登録</Link></div>
                <table className="table">
                    <tbody>
                        {
                            this.state.list.map(item => (
                                <tr key={item.docId + String(new Date())}>
                                    <td>{item.docId}</td>
                                    <td>{item.name}</td>
                                    <td>{item.email}</td>
                                    <td><Link to={`/Detail/${item.docId}`}>詳細</Link></td>
                                </tr>
                            ))
                        }
                    </tbody>
                </table>
            </div>
        );
    }
}

export default Index;
```

#### 実装方法の考察

上記実装では、componentDidMount()でgetData()を実行し値を取得すると同時に、onSnapshot()にて変化を監視し、変更が検知された際にもgetData()を実行しています。多くの場合、DidMount時で十分なのですが、これ以降で実装する新規登録や更新処理において、処理完了時にTopページ（"/")にリダイレクトしていますが、そのタイミングではまだdb(firestore）に更新が反映されていないため、リスト表示と操作にギャップが発生するのうめるために、firestoreでの反映が完了した時点で、再度getData()が行われるようにしています。

この実装はこのサンプルの範囲ではうまく稼働しますが、他人による更新がリアルタイムに反映されたり、検索やページネーション等の機能を加えた際、よきせぬ動きになる場合があります。目的に応じて更新ロジックを変更する必要があります。

### Detail.js

次に、詳細表示、更新、削除機能を持ったページを作成します。
Create.jsをベースに以下のようにしました。

ポイントは、FormikのinitialValuesに検索結果をセットしているところです。また、そのためにenableReinitializeをtrueにしています。

```
<Formik
    enableReinitialize
    initialValues={{ name: this.state.member.name, email: this.state.member.email }}
```

以下、実装です。

```js:Detail.js
import React from 'react';
import { Form, FormGroup, Label, Input, Button, FormFeedback } from 'reactstrap';
import { Link } from 'react-router-dom';
import { Formik } from 'formik';
import * as Yup from 'yup';
import firebase, { db } from '../Firebase';

class Detail extends React.Component {

    state = {
        member: { name: '', email: '' }
    }

    //更新ボタンが押されたら
    handleOnSubmit = (values) => {
        db.collection("members").doc(this.props.match.params.uid).update({
            name: values.name,
            email: values.email
        });

        //Topに移動
        this.props.history.push("/");

    }

    //uidで指定したメンバーの値を取得
    getMember = async (uid) => {
        const docRef = db.collection("members").doc(uid);
        const doc = await docRef.get();
        //ドキュメントの存在確認
        if (doc.exists) {
            this.setState({
                member: doc.data(),
            });
        }else{
            //なければ404ページへ
            this.props.history.push("/404");
        }
    }

    //delete
    handleDelete = (uid) => {
        if (window.confirm('削除しますか？')) {
            db.collection("members").doc(uid).delete();
            this.props.history.push("/");
        }
    }

    //値を取得
    componentDidMount = () => {
        this.getMember(this.props.match.params.uid);
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">詳細・編集</h3>
                <div className="text-right my-3"><Link to="/">一覧へ戻る</Link></div>
                <Formik
                    enableReinitialize
                    initialValues={{ name: this.state.member.name, email: this.state.member.email }}
                    onSubmit={values => this.handleOnSubmit(values)}
                    validationSchema={Yup.object().shape({
                        name: Yup.string().required('氏名は必須です。'),
                        email: Yup.string().email('emailの形式ではありません。').required('Emailは必須です。'),
                    })}
                >
                    {
                        ({ handleSubmit, handleChange, handleBlur, values, errors, touched }) => (
                            <Form onSubmit={handleSubmit}>
                                <FormGroup>
                                    <Label for="name">氏名</Label>
                                    <Input
                                        type="text"
                                        name="name"
                                        id="name"
                                        value={values.name}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.name && errors.name)}
                                    />
                                    <FormFeedback>
                                        {errors.name}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup>
                                    <Label for="email">Email</Label>
                                    <Input
                                        type="email"
                                        email="email"
                                        id="email"
                                        value={values.email}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.email && errors.email)}
                                    />
                                    <FormFeedback>
                                        {errors.email}
                                    </FormFeedback>
                                </FormGroup>
                                <Button type="submit" color="success">更新</Button>
                            </Form>
                        )
                    }
                </Formik>
                <div className="my-3">
                    <Button color="danger" onClick={() => this.handleDelete(this.props.match.params.uid)}>削除</Button>
                </div>
            </div>
        );
    }
}

export default Detail;
```

簡単ではありますが、以上です。

## 応用

* 各種Formのバリデーションを知りたければ[こちら](https://qiita.com/zaburo/items/da787c282f5178a5f68e)。
* ページネーションや検索は[こちら](https://qiita.com/zaburo/private/5b49e527e0e58e74aef3)。
* あと、2019年12月現在、Formikが変なエラー？を吐くようになっていて、その対応方は[こちら](https://qiita.com/zaburo/items/4ce83eb69b4e1cc93db3)。