masalibの日記

システム開発、運用と猫の写真ブログです

React Firebase入門 Firestoreの基礎(CRUD処理)

FirebaseにはFirestoreとFirebase Realtime Databaseの2つのDBがあります。 ChatとかのデータはFirebase Realtime Databaseでトランザクションを貼りたい人はFirestoreのイメージです。 どちらも最終的にはやりたいのですが、ユーザーの項目に追加するのに使いたかったので Firestoreを勉強することにしました

React Firebase入門シリーズ
1-1・ Firebase初期設定とFirebaseAuthのSignUp
1-2・ 「react-hook-form」を入れてみた
1-3・ AuthのSignUpの通信エラー対応
2 ・ FirebaseAuthのログイン処理
3 ・ FirebaseAuthのログイン認証とログアウト処理
4 ・ FirebaseAuthのパスワード初期化処理
5 ・ FirebaseAuthのメールアドレスの有効化
6 ・ FirebaseAuthのメールアドレスとパスワード変更
7 ・ FirebaseAuthの表示名変更
8-1・ FirebaseAuthのアバター変更:基礎
8-2・ FirebaseAuthのアバター変更:画像切り抜き
9-1・FirestoreのCRUD 今ここ
9-2・Firestoreのデータ取得補足

Firestoreとは

RDBでもなく、mongoDBでもない・・・Firebaseが独自に作ったNoSQL ドキュメント データベース。


公式( https://firebase.google.com/docs/firestore?hl=ja )の画像です。

いつもの対比表

RDB FireStore
DB プロジェクトで1つなのでプロジェクトIDになる
テーブル コレクション(collection)
レコード ドキュメント(document)
カラム データ(data)

リアルタイムでの同期処理にも対応しています。 個人的には同期処理はFirebase Realtime Databaseがあるからいらないじゃないの?と思っています。

まだ機能が足りていないみたいで、先日にやっと「Whereの=!」が使えるようになりました。

MongoDBならすごい前から実装されていた機能なので不思議です。

CRUD(クラッド)とは

ほとんど全てのコンピュータソフトウェアが持つ永続性の4つの基本機能のイニシャルを並べた用語。 その4つとは、Create(生成)、Read(読み取り)、Update(更新)、Delete(削除)です。DB接続したらこの処理は確認する。これは基礎中の基礎。

事前作業

Firestoreを使うにはFirebaseのコンソールでFirestoreの初期設定をおこなう必要があります

  1. メニューのFirestoreを押して、作成するのボタンを押す
  2. 権限の設定をおこないます。本番モードとテストモードとありますが今の所はテストモードを選択します。
    最終的にはログインしていないと書き込めないなどの設定が必要かと思われます。
  3. ロケーション(リージョン)を選択します。他のサービスで選択すると選ぶ事はできないようです。私は一番近い「asia-northast1」にしました。
  4. 作成後にコレクション(テーブル)を作成します。参考にしたサイトがmembersだったの同じように作成しました。

プログラム

構造としてはシンプルだと思います
/screens/Edit.js(新規作成、更新、削除機能)
/screens/Index.js(一覧)

便宜上、フォルダを作ってます。

react-router-domの設定は以下のとおりです

<Route path="/screens/index" component={screensIndex} />
<Route path="/screens/edit/:docId" component={screensEdit} />
<Route path="/screens/edit" component={screensEdit} />

わかりにくいですが一覧、更新、新規作成の順です。 更新と新規の順番は必ず一致しないと更新にパラメータが取得できません

firebaseの設定

初期設定の記事からはちょっと更新されています。

React Firebase入門 初期設定とsignup - masalibの日記

修正前はappというconstを設定していたのですが、なぜかこの状態だと タイムスタンプが取れないという状況が発生しました

import firebase from "firebase/app"
import "firebase/auth"
import "firebase/storage";
import 'firebase/firestore';

- const app = firebase.initializeApp({
+ const firebaseConfig = {
    apiKey: process.env.REACT_APP_APIKEY,
    authDomain: process.env.REACT_APP_AUTHDOMAIN,
    databaseURL: process.env.REACT_APP_DATABASEURL,
    projectId: process.env.REACT_APP_PROJECT_ID,
    storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
    messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
    appId: process.env.REACT_APP_APP_ID,
    measurementId: process.env.REACT_APP_MEASUREMENT_ID
+ };
- });

+ firebase.initializeApp(firebaseConfig);

var auth_obj = firebase.auth();
if (process.env.REACT_APP_HOST === "localhost") {
    console.log("useEmulator:auth")
    auth_obj.useEmulator("http://localhost:9099")
} 

var db_obj = firebase.firestore();
if (process.env.REACT_APP_HOST === "localhost") {
  db_obj.useEmulator("localhost", 8080);
  console.log("useEmulator:firestore")
}
var storage_obj = firebase.storage();

+ export default firebase;
- export default app;
export const db = db_obj;
export const auth = auth_obj;
export const storage = storage_obj;

一覧

Firebase一覧の例

  • 概要はmembersというcollectionからデータを取得して画面に描画しています。
  • テーブルの部分はmaterial-uiを使用しています。テーブルにも複数あるのですが、一番簡単なSimple Tableを使用しています。種類が知りたい人は公式のReact Table component - Material-UIを参照してください。

プログラム

import React, { useState, useEffect,useRef } from "react";
import  { db }  from "../../firebase"
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import {
    Typography,
    Table,          
    TableBody,      
    TableCell,      
    TableContainer, 
    TableHead,      
    TableRow,       
    Paper,          
  } from '@material-ui/core';
import { Link } from "react-router-dom"
import moment from 'moment';

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
        padding: 16, 
        margin: 'auto',
        maxWidth: 480
    },
    table: {
        margin: 'auto',
        minWidth: 600,
        maxWidth: 960

    },
  })
);
export const Index = () => {

    // 入力用DOMノードへの参照を保持する
    const inputRef = useRef();
    const classes = useStyles();//Material-ui
    const [list, setList] = useState([])

    useEffect(() => {
         async function fetchData() { // featchDataという関数を定義し、それにasyncをつける
            const colRef = db.collection("members")
            .orderBy('createdAt', 'desc')
            .limit(10);
            const snapshots = await colRef.get();
            const docs = snapshots.docs.map(doc => doc.data());
            console.log(docs)
            setList(docs)
        }
        fetchData();
    },[inputRef]);
    
    return (
        <>
            <Typography variant="h4" align="center" component="h1" gutterBottom>
                一覧表示
            </Typography>
            <TableContainer component={Paper}>
                <Table className={classes.table} aria-label="simple table">
                    <TableHead>
                        <TableRow>
                            <TableCell>ID</TableCell>
                            <TableCell>Email</TableCell>
                            <TableCell>ハンドル名</TableCell>
                            <TableCell>作成日</TableCell>
                            <TableCell>操作</TableCell>
                        </TableRow>
                    </TableHead>
                    <TableBody>
                    {
                    list.map((item, index) => {
                        //console.log(item)    
                        // {item.docId}
                        return (
                            <TableRow key={item.docId}>
                                <TableCell component="th" scope="row">
                                    {item.docId}
                                </TableCell>
                                <TableCell >{item.email}</TableCell>
                                <TableCell >{item.displayName}</TableCell> 
                                <TableCell >
                                  { moment(item.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}
                                </TableCell> 
                                <TableCell ><Link to={`./edit/${item.docId}`}>編集</Link></TableCell> 
                            </TableRow>
                            )
                        })
                    }
                    </TableBody>
                </Table>
                </TableContainer>

            <Typography className={classes.subtitle2} variant="subtitle2"><Link to="/screens/edit">新規作成</Link></Typography>
            <Typography className={classes.subtitle2} variant="subtitle2"><Link to="/">Homeに戻る</Link></Typography>

        </>

    );
}

プログラム解説

一覧は以下のデータで取得されます

const colRef = db.collection("members")
   .orderBy('createdAt', 'desc')
   .limit(10);

データ取得については色々と問題がありました。本筋とは関係ないので補足記事を参考にしてください

masalib.hatenablog.com

const snapshots = await colRef.get();
const docs = snapshots.docs.map(doc => doc.data());
console.log(docs)

snapshotsというデータを取得するオブジェクトを作ってFirebaseと接続してデータを取得してます。

snapshots以下のデータが入ります

メソッド FireStore
doc 取得したデータの本体になるが配列ではなくオブジェクト。
実データはさらにdata()の関数を使用しないと取得できない
empty データの有無を表している
データあり:false
データなし:true
size 取得したレコードの件数
metadata.hasPendingWrites バックエンドにまだ書き込まれていないローカル変更がドキュメントにあるかどうか
metadata.fromCache オフラインでローカルのキャッシュから取得した場合はtrueになる。
query すいません。わからなかったです
forEach docと同じでデータ本体です
docChanges すいません。わからなかったです
constructor すいません。わからなかったです

取得したデータを配列にいれて表示の部分でmapで回しているだけです

<Link to={`./edit/${item.docId}`}>編集</Link>

でeditのコンポーネントにdocidのパラメータを渡しています。この部分はrails形式なのかな

未作成の機能

  • 0件時の処理をいれるべきなのですが、いれていません
  • リアルタイム更新ができるみたいなのですが、参考にしたサイトはclassコンポーネントだったので私がつくっているfunctionコンポーネントではどうやって作るのかわからなかったので後回しにした
  • ページネーション機能・・・これまた時間がかかりそうだったので後回しにした

新規作成、更新、削除機能

参考にしたサイトだと新規作成と更新は別に作っていましたが、同じような構造を作るのがめんどくさいので1つにしました

更新時には一覧から渡ってきたパラメータがpropsに入るので そちらをもとにデータを取得しています。これは一般的なフレームワークなら提供されている機能かと思う。

新規作成、更新、削除機能

  • パラメータ(docId)の有無で新規なのか、更新なのか判別しています。

  • 新規の場合はdocId(ユニークID)を発行して、それをもとにデータをいれています。

  • 更新の場合はdocIdをもとにアップデートしています。

  • 削除の場合はdocIdをもとにdeleteしています。

  • バリデーションはreact-hook-formを使用しています。詳細はmasalib.hatenablog.com

プログラム

クリックすると展開されます(長文なので注意)

import React, { useState,useReducer,useEffect } from "react";
import firebase, { db }  from "../../firebase"
import { useForm } from "react-hook-form";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import {
    Typography,
    Paper,
    Button,
    TextField,
  } from '@material-ui/core';
import { Link , useHistory} from "react-router-dom"

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
        padding: 16, 
        margin: 'auto',
        maxWidth: 480
    },
    updateProfileBtn: {
      marginTop: theme.spacing(2),
      flexGrow: 1,
      color:'primary'
    },
    imagephotoURL: {
        width: "80%",
        margin: '10px',
        borderRadius: '50%'
    },
    inputFile: {
        display: "none"
    },
    subtitle2: {
        color:"#757575"
    },
    modal: {
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
    },
    paper: {
        backgroundColor: theme.palette.background.paper,
        border: '2px solid #000',
        boxShadow: theme.shadows[5],
        padding: theme.spacing(2, 4, 3),
    },
    inputFilebtn: {
        position: "absolute",
        top: "80%",
        left: "55%",
    },


  })
);

//state type
type State = {
  username: string,
  displayName:  string,
};

let initialState: State = {
  username: "",
  displayName: "",
};

type Action =
  | { type: "setUsername", payload: string }
  | { type: "setDisplayName", payload: string }
  | { type: "setIsError", payload: boolean };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "setUsername":
      return {
        ...state,
        username: action.payload
      };
    case "setDisplayName":
    return {
        ...state,
        displayName: action.payload
    };
    case "setIsError":
      return {
        ...state,
        isError: action.payload
      };
    default:
       return state;
  }
};


export const Edit = (props) => {

    //更新時の処理
    const docId  = props.match.params.docId   //画面からわたってきたパラメータ

    const classes = useStyles();//Material-ui
    const [state, dispatch] = useReducer(reducer, initialState);
    const [error, setError] = useState("")
    const [successMessage, setSuccessMessage] = useState("")
    const { register, handleSubmit, errors ,formState} = useForm();
    const history = useHistory()
   
    useEffect(() => {
         async function fetchData() { 
            console.log("render")
            console.log(docId)
            if (docId){
                await db.collection("members").where("docId", "==", docId)
                .get()
                .then(function(querySnapshot) {
                    querySnapshot.forEach(function(doc) {
                        // doc.data() is never undefined for query doc snapshots
                         dispatch({
                            type: "setUsername",
                            payload: doc.data().email
                        });
                        dispatch({
                            type: "setDisplayName",
                            payload: doc.data().displayName
                        });
                        console.log("データ読み込み内部のrender")

                    });
                })
                .catch(function(error) {
                    console.log("Error getting documents: ", error);
                });
            }    
        }
        fetchData();
    },[docId]);

    async function handleCreate () {  //react-hook-formを導入したためevent -> dataに変更
        const docId = db.collection("members").doc().id;

        let timestamp = firebase.firestore.FieldValue.serverTimestamp()
        db.collection("members").doc(docId).set({
            docId: docId,
            displayName: state.displayName,
            email: state.username,
            createdAt: timestamp,
            updatedAt: timestamp,
        });

        setSuccessMessage("更新しました。")
        setTimeout(function(){
            console.log("リダレクト処理")
            history.push("/screens/index")
        },2000);        
    }

    async function handleUpdate () {  //react-hook-formを導入したためevent -> dataに変更
        console.log("update proc start")
        setSuccessMessage("")
        setError("")

        let timestamp = firebase.firestore.FieldValue.serverTimestamp()
        db.collection("members").doc(docId).update({
            displayName: state.displayName,
            email: state.username,
            updatedAt: timestamp,
        });

        setSuccessMessage("更新しました。")
        setTimeout(function(){
            console.log("リダレクト処理")
            history.push("/screens/index")
        },2000);

        console.log("update proc end")
    }

    async function handleDelete (data) {  //react-hook-formを導入したためevent -> dataに変更

        if (window.confirm('削除しますか?')) {
            db.collection("members").doc(docId).delete();
            setSuccessMessage("削除しました")
            setTimeout(function(){
                console.log("リダレクト処理")
                history.push("/screens/index")
            },2000);
        }
    }


    const handleUsernameChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
        dispatch({
            type: "setUsername",
            payload: event.target.value
        });
    };

    const handleDisplayNameChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
        dispatch({
            type: "setDisplayName",
            payload: event.target.value
        });
    };    

    //あとで原因を調べる。わからない場合は別のツールを検討する
    formState.isSubmitted = false   //一回submittedになるとレンダリングが遅くなり、変な動きするので強制的にfalseにする

    return (
        <div className={classes.container} >
            <Typography variant="h4" align="center" component="h1" gutterBottom>
                {docId && <>XXX更新</>}
                {!docId && <>XXX新規作成</>}
            </Typography>
            {error && <div style={{ color: "red" }}>{error}</div>}
            {successMessage && <div variant="danger">{successMessage}</div>}

            <form  noValidate autoComplete="off">
                <Paper style={{ padding: 16 }}>
                    <TextField
                        fullWidth
                        id="username"
                        name="username"
                        type="email"
                        label="Email"
                        //placeholder="Email"
                        margin="normal"
                        value={state.username}
                        onChange={handleUsernameChange}
                        inputRef={register({pattern: /^[A-Za-z0-9]{1}[A-Za-z0-9_.-]*@{1}[A-Za-z0-9_.-]{1,}\.[A-Za-z0-9]{1,}$/ })}
                    />
                    {errors.username?.type === "pattern" &&
                    <div style={{ color: "red" }}>メールアドレスの形式で入力されていません</div>}

                    <TextField
                        fullWidth
                        id="displayName"
                        name="displayName"
                        type="text"
                        label="表示名"
                        placeholder="ハンドル名を入力してください"
                        margin="normal"
                        value={state.displayName}
                        onChange={handleDisplayNameChange}
                        inputRef={register({ required: true, minLength: 4 })}
                    />
                    {errors.displayName?.type === "required" &&
                    <div style={{ color: "red" }}>表示名を入力してください</div>}
                    {errors.displayName?.type === "minLength" &&
                    <div style={{ color: "red" }}>表示名は4文字以上で入力してください</div>}


                    {docId && 
                    <>
                        <Button
                        variant="contained"
                        size="large"
                        fullWidth
                        color="primary"
                        className={classes.updateProfileBtn}
                        onClick={handleSubmit(handleUpdate)}
                        >
                        更新
                        </Button>

                        <Button
                        variant="contained"
                        size="large"
                        fullWidth
                        color="secondary"
                        className={classes.updateProfileBtn}
                        onClick={handleSubmit(handleDelete)}
                        >
                        削除
                        </Button>
                    </>
                    }
                    {!docId && 
                        <Button
                        variant="contained"
                        size="large"
                        fullWidth
                        color="primary"
                        className={classes.updateProfileBtn}
                        onClick={handleSubmit(handleCreate)}
                    >
                        新規作成
                    </Button>
                    }

                    
                </Paper>

            </form>
            <Typography className={classes.subtitle2} variant="subtitle2"><Link to="/screens/index">一覧に戻る</Link></Typography>
            <Typography className={classes.subtitle2} variant="subtitle2"><Link to="/">Homeに戻る</Link></Typography>
        </div>

    );
}



//export const ScreensCreate;

プログラム解説(新規作成)

データを入力した新規作成のボタンを押すとhandleCreateの関数が起動します。

    async function handleCreate (data) {  //react-hook-formを導入したためevent -> dataに変更
        const docId = db.collection("members").doc().id;
        let timestamp = firebase.firestore.FieldValue.serverTimestamp()
        db.collection("members").doc(docId).set({
            docId: docId,
            displayName: state.displayName,
            email: state.username,
            createdAt: timestamp,
            updatedAt: timestamp,
        });

        setSuccessMessage("更新しました。")
        setTimeout(function(){
            console.log("リダレクト処理")
            history.push("/screens/index")
        },2000);        
    }
  • 「const docId = db.collection("members").doc().id」はユニークのIDを発行してくれます
  • 「let timestamp = firebase.firestore.FieldValue.serverTimestamp()」はFirebase側用意してくれているTimestampオブジェクトです。表示で使う場合はmomentのモジュールを使わないと表示できない。
{ moment(item.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}
// => 例 2020-12-02 02:27:00
  • 「db.collection("members").doc(docId).set」でデータを更新しています。カラムについてはNoSQL ドキュメント データベースなので既存のデータとか気にしなくてもいいです。create文ではなくset文・・・ややこしいな~createにしろよ😅

プログラム解説(更新)

新規と違いパラメータ(docId)をもとにデータ取得しています。 useEffectと使用して初回だけ取得するようにしています。依存関係はdocIdなので基本的にはこの値は変わらないです。あとはdispatchを利用してセットしているだけです。

    useEffect(() => {
         async function fetchData() { 
            console.log("render")
            console.log(docId)
            if (docId){
                await db.collection("members").where("docId", "==", docId)
                .get()
                .then(function(querySnapshot) {
                    querySnapshot.forEach(function(doc) {
                        // doc.data() is never undefined for query doc snapshots
                         dispatch({
                            type: "setUsername",
                            payload: doc.data().email
                        });
                        dispatch({
                            type: "setDisplayName",
                            payload: doc.data().displayName
                        });
                        console.log("データ読み込み内部のrender")

                    });
                })
                .catch(function(error) {
                    console.log("Error getting documents: ", error);
                });
            }    
        }
        fetchData();
    },[docId]);

データを更新して更新のボタンを押すとhandleUpdateの関数が起動します。
新規と違うのはidを取得していない所です。そこまで難しい所はないかと思う

        db.collection("members").doc(docId).update({
            displayName: state.displayName,
            email: state.username,
            updatedAt: timestamp,
        });

プログラム解説(削除)

削除のボタンを押すとhandleDeleteの関数が起動します。
確認のアラートが表示されてOKをおされた場合に削除が実行されます。そこまで難しい所はないかと思う

    async function handleDelete (data) {  //react-hook-formを導入したためevent -> dataに変更

        if (window.confirm('削除しますか?')) {
            db.collection("members").doc(docId).delete();
            setSuccessMessage("削除しました")
            setTimeout(function(){
                console.log("リダレクト処理")
                history.push("/screens/index")
            },2000);
        }
    }

感想

  • mongoDBと比べると機能がしょぼい。データ取得部分はまじで困る。
  • 単一テーブルでかつ簡単な取得条件ならいいけど、複雑になったらマジで無理。 ただお金がなくFirebaseで完結させたいなら一応はいけるレベル。技術者のレベルに依存するのであまりオススメできない。