Help us understand the problem. What is going on with this article?

続:React + FirestoreでCRUD

以前も同じ記事を書いたのですが、人の記事をなぞっただけなので自分で書いてみました。
さらに、実案件を想定して、ファイルアップロードやカレンダーも含めたCRUDを試してみました。

追記

ここの内容はCRUDとしては少々Formが複雑すぎるので、簡単版を作成しました。そちらもご利用ください。

仕様・完成イメージ

CRUDといいつつ、Editページに詳細表示機能を兼ねさせたり削除機能を追加することにより、以下の3ページを作成することでことは足ります。

  • Create(新規登録)
  • Index(一覧)
  • Edit(編集+詳細画面を兼ねる+削除機能追加)
  • Show(いらないけど参考実装)

スクリーンショット 2019-12-03 11.04.50.png

完成版のgithubのリポジトリはこちら

実装の手順

  1. データがないと始まらないのでまずCreateを実装します。
  2. 次に一覧を実装します。
  3. 最後にEditを実装します。

準備

作業場所作成とモジュールのインストール

作業場所を作成し、必要なモジュールをインストールします。

create-react-app crud
cd crud

yarn add bootstrap firebase formik moment react-datepicker react-firebase-file-uploader react-router-dom reactstrap yup

必要なファイルの作成

必要なファイルを生成しておきます。ファイルはsrc以下に作成します。

mkdir src/screens
touch src/screens/Create.js
touch src/screens/Index.js
touch src/screens/Edit.js
touch src/screens/Show.js

touch src/Firebase.js

bootstrapのcssを提供する

index.js
import React from 'react';
import ReactDOM from 'react-dom';
+import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

実装

まずはApp.jsでルーティングの設定を行います。
各コンポーネントが無いとエラーがでるので、作成の順番に注意してください。

App.js

App.js
import React from 'react';
import './App.css';

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

//screens
import Index from './screens/Index';
import Create from './screens/Create';
import Show from './screens/Show';
import Edit from './screens/Edit';

class App extends React.Component {
    render() {
        return (
            <Router>
                <Switch>
                    <Route exact path="/" component={Index} />
                    <Route exact path="/create" component={Create} />
                    <Route exact path="/show/:uid" component={Show} />
                    <Route exact path="/edit/:uid" component={Edit} />
                    <Route render={() => <div>Page not found.</div>} />
                </Switch>
            </Router>
        );
    }
}

export default App;

Create.js

ちょっと複雑にはなりますが、ここでは実践で利用する各種Form要素を利用してみたいと思います。
主な要素は

  • 普通のInput
  • Select
  • Radioボタン
  • カレンダー
  • ファイルアップロード
  • チェックボックス

あたりです。

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

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

import FileUploader from "react-firebase-file-uploader";

import moment from 'moment';

//react-datepicker
import DatePicker, { registerLocale } from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
//for locale ja
import ja from 'date-fns/locale/ja';
registerLocale('ja', ja);

class Create extends React.Component {

    state = {
        avatarUrl: '',
        avator: '',
        isUploading: false,
        progress: 0,
        spinnerHidden: true,
    }

    handleOnSubmit = async (values) => {
        // alert(JSON.stringify(values));
        this.setState({ spinnerHidden: false });

        //dbへ書き込み
        const docId = db.collection("members").doc().id;
        await db.collection("members").doc(docId).set({
            docId: docId,
            email: values.email,
            area: values.area,
            gender: values.gender,
            birthday: firebase.firestore.Timestamp.fromDate(new Date(values.birthday)),
            avatarUrl: values.avatarUrl,
            agree: values.agree,
            createdAt: firebase.firestore.FieldValue.serverTimestamp(),
        });

        this.setState({ spinnerHidden: true });
        alert("登録しました。");
    }

    //upload
    handleUploadStart = () => this.setState({ isUploading: true, progress: 0 });
    handleUploadError = error => {
        this.setState({ isUploading: false });
        console.log(error);
    }
    handleUploadSuccess = async filename => {
        await this.setState({ avator: filename, isUploading: false });
        const url = await firebase.storage().ref("images").child(filename).getDownloadURL();
        await this.setState({ avatarUrl: url });
        return url;
    }
    handleProgress = progress => this.setState({ progress: progress });

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">新規登録</h3>
                <div className="text-right my-3 mr-5"><Link to="/">一覧へ戻る</Link></div>
                <Formik
                    initialValues={{ email: '', area: '', gender: '', birthday: moment(new Date()).format('YYYY/MM/DD'), avatarUrl: '', agree: '' }}
                    onSubmit={this.handleOnSubmit}
                    validationSchema={Yup.object().shape({
                        email: Yup.string().email().required(),
                        area: Yup.string().oneOf(['関東', '関西']).required(),
                        gender: Yup.string().oneOf(['male', 'female']).required(),
                        avatarUrl: Yup.string().required(),
                        agree: Yup.boolean().oneOf([true]).required(),
                    })}
                >
                    {
                        ({ handleSubmit, handleChange, handleBlur, values, errors, touched, setFieldValue }) => (
                            <Form className="col-8 mx-auto" onSubmit={handleSubmit}>
                                <FormGroup>
                                    <Label for="email">Email</Label>
                                    <Input
                                        type="email"
                                        name="email"
                                        id="email"
                                        value={values.email}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.email && errors.email)}
                                    />
                                    <FormFeedback>
                                        {errors.email}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup>
                                    <Label>■お住いの地域</Label>
                                    <Input
                                        type="select"
                                        name="area"
                                        id="area"
                                        value={values.area}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.area && errors.area)}
                                    >
                                        <option value="選択して下さい">選択して下さい</option>
                                        <option value="関東">関東</option>
                                        <option value="関西">関西</option>
                                    </Input>
                                    <FormFeedback>
                                        {errors.area}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup className="mb-4">
                                    <legend className="col-form-label">■性別</legend>
                                    <FormGroup inline check>
                                        <Label check>
                                            男性:<Input
                                                type="radio"
                                                name="gender"
                                                id="male"
                                                value="male"
                                                onChange={handleChange}
                                            />
                                        </Label>
                                    </FormGroup>
                                    <FormGroup inline check>
                                        <Label check>
                                            女性:<Input
                                                type="radio"
                                                name="gender"
                                                id="female"
                                                value="female"
                                                onChange={handleChange}
                                            />
                                        </Label>
                                    </FormGroup>
                                    <span className="text-danger small">{touched.gender && errors.gender ? errors.gender : null}</span>
                                </FormGroup>
                                <FormGroup>
                                    <legend className="col-form-label">■直近の誕生日</legend>
                                    <DatePicker
                                        locale="ja"
                                        name="birthday"
                                        id="birthday"
                                        value={values.birthday}
                                        dateFormat="yyyy/MM/dd"
                                        customInput={<Input invalid={Boolean(errors.birthday)} />}
                                        onChange={date => setFieldValue("birthday", moment(date).format('YYYY/MM/DD'))}
                                    />
                                </FormGroup>
                                <FormGroup>
                                    <legend className="col-form-label">■プロファイル画像</legend>
                                    <FileUploader
                                        accept="image/*"
                                        name="avatarUrl"
                                        randomizeFilename
                                        storageRef={firebase.storage().ref("images")}
                                        onUploadStart={this.handleUploadStart}
                                        onUploadError={this.handleUploadError}
                                        onUploadSuccess={async (filename) => {
                                            const path = await this.handleUploadSuccess(filename); //ここもawaitにしないとurl取得できない
                                            setFieldValue("avatarUrl", path); //値のセットとエラーの削除
                                        }}
                                        onProgress={this.handleProgress}
                                    />
                                    <span className="text-danger small">{touched.avatarUrl && errors.avatarUrl ? errors.avatarUrl : null}</span>
                                    {this.state.isUploading ? <p>Uploading... {this.state.progress}%</p> : null}
                                    {this.state.avatarUrl ? <img src={this.state.avatarUrl} width="120" alt="" className="my-2"/> : null}
                                </FormGroup>
                                <FormGroup className="my-4">
                                    <legend className="col-form-label">■規約に同意して下さい。</legend>
                                    <FormGroup inline check>
                                        <Input
                                            type="checkbox"
                                            name="agree"
                                            id="agree"
                                            value={values.agree}
                                            onChange={handleChange}
                                        />
                                        <Label for="agree" check>同意する。</Label>
                                        <span className="text-danger small">{touched.agree && errors.agree ? errors.agree : null}</span>
                                    </FormGroup>

                                </FormGroup>
                                <div>
                                    <Button type="submit" color="primary">
                                        <Spinner color="light" size="sm" className="mr-1" hidden={this.state.spinnerHidden} />
                                        登録する
                                        </Button>
                                </div>
                            </Form>
                        )
                    }
                </Formik>
            </div>
        );
    }
}

export default Create;

Index.js

ひとまず一覧表示してEditやShowにリンクするだけです。

Index.js
import React from 'react';
import { Button } from 'reactstrap';
import { Link } from 'react-router-dom';

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

class Index extends React.Component {

    state = {
        listData: [],
    }

    //db変化時のコールバック
    onCollectionUpdate = (querySnapshot) => {
        const docs = querySnapshot.docs.map(doc => doc.data());
        this.setState({ listData: docs });
    }

    componentDidMount = () => {
        //dbの変化を監視(変化が無くても初回は実行される)
        this.unsubscribe = db.collection("members")
            .orderBy('createdAt','desc')
            .onSnapshot(this.onCollectionUpdate);
    }

    componentWillUnmount = () => {
        //subscribe停止
        this.unsubscribe();
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">メンバー一覧</h3>
                <Link to="/create"><Button color="primary" className="m-2">新規登録</Button></Link>
                <table className="table">
                    <thead>
                        <tr>
                            <th>UID</th>
                            <th>Email</th>
                            <th>Avatar</th>
                            <th>詳細</th>
                            <th>編集</th>
                        </tr>
                    </thead>
                    <tbody>
                        {
                            this.state.listData.map(member => (
                                <tr key={member.docId}>
                                    <td>{member.docId}d</td>
                                    <td>{member.email}</td>
                                    <td><img src={member.avatarUrl} height="30" width="30" alt="" /></td>
                                    <td><Link to={`/show/${member.docId}`}><Button size="sm" color="primary">詳細</Button></Link></td>
                                    <td><Link to={`/edit/${member.docId}`}><Button size="sm" color="success">編集</Button></Link></td>
                                </tr>
                            ))
                        }
                    </tbody>
                </table>
            </div>
        );
    }
}

export default Index;

Edit.js

Create.jsの画面をベースに指定したメンバー(ドキュメント)の情報をデフォルト値としてセットし、更新があれば更新することになります。削除ボタンもEditに実装します。

Edit.js
import React from 'react';
import { Form, FormGroup, FormFeedback, Label, Input, Button, Spinner } from 'reactstrap';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { Link } from 'react-router-dom';

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

import FileUploader from "react-firebase-file-uploader";

import moment from 'moment';

//react-datepicker
import DatePicker, { registerLocale } from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
//for locale ja
import ja from 'date-fns/locale/ja';
registerLocale('ja', ja);

class Edit extends React.Component {

    state = {
        avatarUrl: '',
        avator: '',
        isUploading: false,
        progress: 0,
        spinnerHidden: true,
        member: { email: '', area: '', gender: '', birthday: '', avatarUrl: '', agree: false }, //初期値ないとWarning出る
    }

    handleOnSubmit = async (values) => {
        // alert(JSON.stringify(values));
        this.setState({ spinnerHidden: false });

        //dbへ書き込み
        const docId = this.props.match.params.uid;
        await db.collection("members").doc(docId).update({
            email: values.email,
            area: values.area,
            gender: values.gender,
            birthday: firebase.firestore.Timestamp.fromDate(new Date(values.birthday)),
            avatarUrl: values.avatarUrl,
            agree: values.agree,
        });

        this.setState({ spinnerHidden: true });
        alert("更新しました。");
    }

    //upload
    handleUploadStart = () => this.setState({ isUploading: true, progress: 0 });
    handleUploadError = error => {
        this.setState({ isUploading: false });
        console.log(error);
    }
    handleUploadSuccess = async filename => {
        await this.setState({ avator: filename, isUploading: false });
        const url = await firebase.storage().ref("images").child(filename).getDownloadURL();
        await this.setState({ avatarUrl: url });
        return url;
    }
    handleProgress = progress => this.setState({ progress: progress });

    //for Edit
    getMember = async (uid) => {
        const docRef = db.collection("members").doc(uid);
        const doc = await docRef.get();
        if (doc.exists) {
            this.setState({
                member: doc.data(),
            });
        } else {
            alert("メンバーが見つかりませんでした。");
        }
    }

    componentDidMount = () => {
        this.getMember(this.props.match.params.uid);
    }

    deleteMember = async (uid) => {
        if (window.confirm('本当に削除しますか?')) {
            await db.collection("members").doc(uid).delete();
            this.props.history.push("/");
        } else {
            return;
        }
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">情報編集</h3>
                <div className="text-right my-3 mr-5"><Link to="/">一覧へ戻る</Link></div>
                <Formik
                    enableReinitialize //これがポイント
                    initialValues={{
                        email: this.state.member.email, //各初期値にdbからの値(このためにenableReinitializeが必要)
                        area: this.state.member.area,
                        gender: this.state.member.gender,
                        birthday: moment(this.state.member.birthday.seconds * 1000).format('YYYY/MM/DD'),
                        avatarUrl: this.state.member.avatarUrl,
                        agree: this.state.member.agree,
                    }}
                    onSubmit={this.handleOnSubmit}
                    validationSchema={Yup.object().shape({
                        email: Yup.string().email().required(),
                        area: Yup.string().oneOf(['関東', '関西']).required(),
                        gender: Yup.string().oneOf(['male', 'female']).required(),
                        avatarUrl: Yup.string().required(),
                        agree: Yup.boolean().oneOf([true]).required(),
                    })}
                >
                    {
                        ({ handleSubmit, handleChange, handleBlur, values, errors, touched, setFieldValue }) => (
                            <Form className="col-8 mx-auto" onSubmit={handleSubmit}>
                                <FormGroup>
                                    <Label for="email">Email</Label>
                                    <Input
                                        type="email"
                                        name="email"
                                        id="email"
                                        value={values.email}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.email && errors.email)}
                                    />
                                    <FormFeedback>
                                        {errors.email}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup>
                                    <Label>■お住いの地域</Label>
                                    <Input
                                        type="select"
                                        name="area"
                                        id="area"
                                        value={values.area}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.area && errors.area)}
                                    >
                                        <option value="選択して下さい">選択して下さい</option>
                                        <option value="関東">関東</option>
                                        <option value="関西">関西</option>
                                    </Input>
                                    <FormFeedback>
                                        {errors.area}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup className="mb-4">
                                    <legend className="col-form-label">■性別</legend>
                                    <FormGroup inline check>
                                        <Label check>
                                            男性:<Input
                                                type="radio"
                                                name="gender"
                                                id="male"
                                                value="male"
                                                onChange={handleChange}
                                                checked={values.gender === "male"} //追加
                                            />
                                        </Label>
                                    </FormGroup>
                                    <FormGroup inline check>
                                        <Label check>
                                            女性:<Input
                                                type="radio"
                                                name="gender"
                                                id="female"
                                                value="female"
                                                onChange={handleChange}
                                                checked={values.gender === "female"} //追加
                                            />
                                        </Label>
                                    </FormGroup>
                                    <span className="text-danger small">{touched.gender && errors.gender ? errors.gender : null}</span>
                                </FormGroup>
                                <FormGroup>
                                    <legend className="col-form-label">■直近の誕生日</legend>
                                    <DatePicker
                                        locale="ja"
                                        name="birthday"
                                        id="birthday"
                                        value={values.birthday}
                                        dateFormat="yyyy/MM/dd"
                                        customInput={<Input invalid={Boolean(errors.birthday)} />}
                                        onChange={date => setFieldValue("birthday", moment(date).format('YYYY/MM/DD'))}
                                    />
                                </FormGroup>
                                <FormGroup>
                                    <legend className="col-form-label">■プロファイル画像</legend>
                                    <FileUploader
                                        accept="image/*"
                                        name="avatarUrl"
                                        randomizeFilename
                                        storageRef={firebase.storage().ref("images")}
                                        onUploadStart={this.handleUploadStart}
                                        onUploadError={this.handleUploadError}
                                        onUploadSuccess={async (filename) => {
                                            const path = await this.handleUploadSuccess(filename); //ここもawaitにしないとurl取得できない
                                            setFieldValue("avatarUrl", path); //値のセットとエラーの削除
                                        }}
                                        onProgress={this.handleProgress}
                                    />
                                    <span className="text-danger small">{touched.avatarUrl && errors.avatarUrl ? errors.avatarUrl : null}</span>
                                    {this.state.isUploading ? <p>Uploading... {this.state.progress}%</p> : null}
                                    {/* sateからvaluesへ参照を切り替え */}
                                    {values.avatarUrl ? <img src={values.avatarUrl} width="120" alt="" className="my-2" /> : null}
                                </FormGroup>
                                <FormGroup className="my-4">
                                    <legend className="col-form-label">■規約に同意して下さい。</legend>
                                    <FormGroup inline check>
                                        <Input
                                            type="checkbox"
                                            name="agree"
                                            id="agree"
                                            value={values.agree}
                                            onChange={handleChange}
                                            checked={values.agree === true}
                                        />
                                        <Label for="agree" check>同意する。</Label>
                                        <span className="text-danger small">{touched.agree && errors.agree ? errors.agree : null}</span>
                                    </FormGroup>

                                </FormGroup>
                                <div>
                                    <Button type="submit" color="success">
                                        <Spinner color="light" size="sm" className="mr-1" hidden={this.state.spinnerHidden} />
                                        更新する
                                        </Button>
                                </div>
                            </Form>
                        )
                    }
                </Formik>
                <div className="col-8 mx-auto my-3">
                    <Button color="danger" onClick={() => this.deleteMember(this.props.match.params.uid)}>データを削除</Button>
                </div>
            </div>
        );
    }
}

export default Edit;

Show.js

Editと表示内容はほぼ同じなので、いらないのですが参考まで。
テキストベースで表示するため型変換等が必要になります。

Show.js
import React from 'react';
import firebase, { db } from '../Firebase';
import { Link } from 'react-router-dom';
import moment from 'moment';


class Show extends React.Component {

    state = {
        member: {}
    }

    getMember = async (uid) => {
        const docRef = db.collection("members").doc(uid);
        const doc = await docRef.get();
        if (doc.exists) {
            this.setState({
                member: doc.data(),
            });
        } else {
            alert("メンバーが見つかりませんでした。");
        }
    }

    componentDidMount = async () => {
        await this.getMember(this.props.match.params.uid);
    }

    render() {

        if(this.state.member.createdAt === undefined){
            return <p>Loading...</p>
        }

        return (
            <div className="container">
                <h3 className="text-center my-5">メンバー詳細</h3>
                <div className="text-right my-3 mr-5"><Link to="/">一覧へ戻る</Link></div>
                <table className="table">
                    <tbody>
                        <tr>
                            <th>UID</th>
                            <td>{this.state.member.docId}</td>
                        </tr>
                        <tr>
                            <th>Email</th>
                            <td>{this.state.member.email}</td>
                        </tr>
                        <tr>
                            <th>居住地域</th>
                            <td>{this.state.member.area}</td>
                        </tr>
                        <tr>
                            <th>Avatar</th>
                            <td><img src={this.state.member.avatarUrl} width="200" alt="" /></td>
                        </tr>
                        <tr>
                            <th>性別</th>
                            <td>{this.state.member.gender}</td>
                        </tr>
                        <tr>
                            <th>生年月日?</th>
                            <td>{moment(this.state.member.birthday.seconds * 1000).format('YYYY/MM/DD')}</td>
                        </tr>
                        <tr>
                            <th>同意</th>
                            <td>{String(this.state.member.agree)}</td>
                        </tr>
                        <tr>
                            <th>登録日時</th>
                            <td>{ moment(this.state.member.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:dd:ss')}</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        );
    }
}

export default Show;
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
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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
ユーザーは見つかりませんでした