Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
53
Help us understand the problem. What is going on with this article?
@zaburo

新:React + FirestoreでCRUD(基礎編)

More than 1 year has passed since last update.

別途CRUDの記事を書いたら「複雑すぎる」ということなので簡単バージョンを書きます。
radio, checkbox, file等、各種Form要素のコントロールを知りたい人は、複雑?バージョンをご覧下さい。

仕様

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

スクリーンショット 2019-12-11 9.34.06.png

準備

create-react-app

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

create-react-app crud-basic
cd crud-basic

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

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

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を利用するのに使います

必要なファイル生成

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

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を利用するための設定を行います。各自の環境に合わせて設定してください。

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

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

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ページへ戻るリンクを設置。

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ページへ戻るリンクを設置。

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用のページも作っておきます。

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

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

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;

動作確認

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

npm start

yarn start

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

実装:本実装

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

Create.js

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

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

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

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 }}

以下、実装です。

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のバリデーションを知りたければこちら
  • ページネーションや検索はこちら
  • あと、2019年12月現在、Formikが変なエラー?を吐くようになっていて、その対応方はこちら
53
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
zaburo
こんにちは。自分用のメモをだらだら公開しています。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
53
Help us understand the problem. What is going on with this article?