🤠

React+TypeScript+Firebase CRUD&認証

2022/10/26に公開約12,700字

この記事について

React+TypeScript+FirebaseでCRUD機能認証機能を実装してみた

前提知識

環境構築

  • プロジェクト作成
npx create-react-app . --template typescript
npm i @material-ui/core
npm i @material-ui/icons
npm i firebase@8.10.0
  • タスク管理とログイン画面の切り替えのためにTS版のreact-router-domをインストール
npm i react-router-dom @types/react-router-dom

FirebaseとReact連携

Firebaseプロジェクト作成

Firebaseのプロジェクト作成にはGoogleのアカウントが必要になります。
Firebaseコンソールからプロジェクトの追加を選択⇒プロジェクト名を入力⇒Googleアナリティクスを使用しない⇒プロジェクトの作成をクリック。

プロジェクトからアプリを追加を選択⇒アプリ名を入力⇒Firebase Hostingの設定を無効⇒アプリを登録

⚙⇒プロジェクトを設定⇒マイアプリ/SDK の設定と構成/構成⇒Firebase SDKを利用するためにコードを確認

.envファイルの作成

プロジェクト直下に.env.localファイルを作成し先ほど作成したAPI Keyと対応させながら貼り付ける。

REACT_APP_FIREBASE_APIKEY=""
REACT_APP_FIREBASE_DOMAIN=""
REACT_APP_FIREBASE_PROJECT_ID=""
REACT_APP_FIREBASE_STORAGE_BUCKET=""
REACT_APP_FIREBASE_SENDER_ID:""
REACT_APP_FIREBASE_APP_ID:""

FirestoreへアクセスするためにFirebaseを初期化

firebase.initializeAppの引数にはfirebaseConfigを渡します。
initializeAppメソッドで初期化されたアプリケーションのオブジェクトはfirebase.appsというプロパティに配列型で格納されていきます。
そのため、firebase.appsに何も含まれない場合のみにinitializeAppメソッドで初期化するといったコードもよく見かけます。

src/Firebase.ts
import firebase from "firebase/app"; //firebase APIを取得
import "firebase/auth";
import "firebase/firestore";
import "firebase/app";

//Firebaseオブジェクトの初期化
const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_APIKEY,
  authDomain: process.env.REACT_APP_FIREBASE_DOMAIN,
  databaseURL: process.env.REACT_APP_FIREBASE_DATABASE,
  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_SENDER_ID,
  appId: process.env.REACT_APP_FIREBASE_APP_ID,
};

//環境変数を呼び出して各パラメータにセットする
const firebaseApp = firebase.initializeApp(firebaseConfig);

//dbとauthを各React Componentから利用可能にする
export const db = firebaseApp.firestore();
export const auth = firebase.auth();

Firestoreの設定



FirestoreのデータをReactでレンダリング

Firestoreからデータフェッチ⇒Reactでレンダリング

  • firebase.tsからdbをimport
  • React Hooksを使うので、useEffect, useStateをimport
  • Firebaseから取得した内容をstateとして保持したいので、配列型でuse Stateを使う
    • アプリケーション起動時にFirebaseからデータを取得
    • db.collection("")でFireStoreのコレクションを選択
  • onSnapshotでFirestoreにシャッターを通してリアルタイムな変更を監視し、複数のオブジェクトをmapメソッドで展開し、その際にsetTasksで配列に格納していく(上書きではないことに注意)。
  • useEffectはCleanUp関数をreturnで定義できる👀わからん△
    • コンポーネントがアンマウントされたときの処理を定義
      • そのときはFirestoreの監視は必要ないので停止したい
  • "tasks"をmap関数の引数taskに流してレンダリング⇒UI表示
src/App.ts
import React, { useState, useEffect } from "react";
import "./App.css";
import { db } from "./firebase";

const App: React.FC = () => {
  const [tasks, setTasks] = useState([{ id: "", title: "" }]);
  useEffect(() => {
    const unSub = db.collection("tasks").onSnapshot((snapshot) => {
      setTasks(
        snapshot.docs.map((doc) => ({
          id: doc.id,
          title: doc.data().title,
        }))
      );
    });
    return () => unSub();
  }, []); //アプリケーション初回起動時のみにデータを読み取りに行くので第2引数は空配列

  return (
    <div className="App">
      {tasks.map((task) => (
        <h3>{task.title}</h3>
      ))}
    </div>
  );
};
export default App;

map関数にkeyの設置

旧配列から新配列を生成するmapメソッドに必要なKeyを追加

src/App.ts
  return (
    <div className="App">
      {tasks.map((task) => (
        <h3 key={task.id}>{task.title}</h3>
      ))}
    </div>
  );

入力フォームと追加ボタンの実装

material-UIを用いて入力フォームを作成します。

src/App.ts
<h1>TaskApp</h1>
<FormControl>
<TextField
  InputLabelProps={{
    shrink: true,
  }}
  label="新しいタスク"
  value={input} //`value`には更新したいstateを指定
  onChange={
    (
      e: React.ChangeEvent<HTMLInputElement> //`onChange`にはユーザが入力するたびに呼び出されるメソッドを指定
    ) => setInput(e.target.value) //e(event)は入力時に発生するオブジェクト
  }
></TextField>
</FormControl>

追加ボタンの実装では、AddCircleIconを押した際に入力内容がPOSTされるような実装を目指す。

src/App.ts
import AddCircleIcon from "@material-ui/icons/AddCircle";

const newTask = (e: React.MouseEvent<HTMLButtonElement>) => {
db.collection("tasks").add({ title: input }); //2つの属性(id, title)のうち、idは自動生成、titleには入力したinputを流す
setInput(""); //次のinput入力のための初期化
};

<button
disabled={!input} //state:inputが空の時にbuttonを非活性にする
onClick={newTask} //ユーザがボタンクリックする度に呼び出される処理をonChangeに格納
>
<AddCircleIcon />
</button>



Taskitemコンポーネントの実装

rafceで関数型コンポーネントのひな形を生成。

TaskItemコンポーネントはAppコンポーネントから各idtitleを引数で受け取るので、引数にはpropsを渡す。propsで受け取るデータ型を宣言して、Genericに型指定を行う。

src/TaskItem.tsx
interface PROPS {
  id: string;
  title: string;
}
const TaskItem: React.FC<PROPS> = (props) => {
  return (
    <div>
    </div>
  )
}
export default TaskItem

次に、リストを展開してレンダリングする(👀レンダリングってううまく言語化できない)
PROPSでは親コンポーネントから受け取るオブジェクトの型を指定する(期待する)。

src/TaskItem.tsx
interface PROPS {
  id: string;
  title: string;
}
const TaskItem: React.FC<PROPS> = (props) => {
  return (
    <div>
    </div>
  )
}
export default TaskItem

親コンポーネントから子コンポーネントへ引数のid, titleを渡す。
この時に型の不一致が起こらないようにReact×TypeScriptで実装を行うようにしている。

src/App.tsx
<List>
{tasks.map((task) => (
  <TaskItem key={task.id} id={task.id} title={task.title}/>
))}
</List>

ほい

編集と削除ボタンの実装

まずは、stateを宣言する。そのstateに対してユーザがどのようなアクションを行うかを想定する。そのアクションに必要な処理とイベントトリガーを加える。

onClickはユーザがタイピングする際に呼び出され、
onChangeはユーザがクリックする際に呼び出される。

src/TaskItem.tsx
import React, { useState } from "react";
import { ListItem, TextField, Grid } from "@material-ui/core";
import EditOutlinedIcon from "@material-ui/icons/EditOutlined";
import DeleteOutlineOutlinedIcon from "@material-ui/icons/DeleteOutlineOutlined";
import { db } from "./firebase"; //編集する際はfirebaseにアクセスするのでモジュールインポートを忘れない!

interface PROPS {
  id: string;
  title: string;
}

const TaskItem: React.FC<PROPS> = (props) => {
  //編集対象となるstateを宣言
  const [title, setTitle] = useState(props.title);
  //編集メソッドを定義。
  const EditTask = () => {
    db.collection("tasks").doc(props.id).set({ title: title }, { merge: true }); //titleのみ更新するのでmerge: trueにする
  };
  //削除メソッドを定義。
  const DeleteTask = () => {
    db.collection("tasks").doc(props.id).delete();
  };

  return (
    <div>
      <ListItem>
        <h2>{props.title}</h2>
        <Grid container justify="flex-end">
	  //編集入力フォームを記載。
          <TextField
            label="Edit Task"
            InputLabelProps={{ shrink: true }}
            value={title}
	    //event型をTSで定義
            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
              setTitle(e.target.value)
            }
          />
        </Grid>
      </ListItem>
      //編集ボタン
      <button>
        <EditOutlinedIcon onChange={EditTask} /> //クリック時に呼ぼ出される処理
      </button>
      //削除ボタン
      <button>
        <DeleteOutlineOutlinedIcon onChange={DeleteTask} /> //クリック時に呼ぼ出される処理
      </button>
    </div>
  );
};

export default TaskItem;

OK

Login機能実装の準備

Login画面は、アプリを表示するメイン画面とは異なるURLの画面を用意したいので、React-Router-DOMを用いて実装を行う(インストール済み)。

React-Router-DOMは、受け取ったpathに応じて表示する画面を切り替えることができるライブらるです。

ログイン画面を作成する。

src/Login.tsx
import React from 'react'

const Login = () => {
  return (
    <div>
      Login
    </div>
  )
}

export default Login

index.tsxでRouterの設定(メイン画面とログイン画面の切り替え条件の設定)を行う。
<BrowserRouter>とFragment内で、切り替えのRouteを設計する。

src/index.tsx
import { Route, BrowserRouter } from "react-router-dom";
import Login from "./Login";
    <BrowserRouter>
      <>
        <Route exact path="/" component={App} />
        <Route exact path="/login" component={Login} />
      </>
    </BrowserRouter>

ほれきた

Login機能の実装

Firebase Authenticatinにてメール認証を有効化

Loginコンポーネントが受け取るpropsはindex.tsxのRouterが保持するHistory state(ページのアクセス履歴を保管するstate)です。データ型は複雑なのでany

新しくユーザーが登録した、ログインした、ログアウトしたなど何らかの認証処理が走る場合に、auth.onAuthStateChangedが毎回呼び出される。

Firebase authのmoduleにはuserがすでに用意されており、
ログインが成功した場合に入力情報がstate: userに格納されていきます。
つまり、このstate: userに何らかのデータが存在するならば適切に認証処理を終えているユーザであると判断できる。

src/Login.tsx
import React, { useState, useEffect } from "react";
import { auth } from "./firebase";

const Login: React.FC = (props: any) => {
  const [isLogin, setIsLogin] = useState(true); //Login or Register
  const [email, setEmail] = useState(""); //入力内容を保持
  const [pw, setPW] = useState(""); //入力内容を保持

  useEffect(() => {
    const unSub = auth.onAuthStateChanged((user) => {
      user && props.history.push("/"); //user内にデータがあるならメイン画面へ遷移
      //&&⇒true, :⇒false
    });
    return () => unSub();
  }, [props.history]);

次に、実際にユーザがメールアドレスとパスワードを入力するために必要な入力フォームをFormControlTextFieldで実装する。

src/Login.tsx
import { Button, FormControl, TextField, Typography } from "@material-ui/core";

<FormControl>
<TextField
  InputLabelProps={{
    shrink: true,
  }}
  name="email"
  label="メールアドレス"
  value={email}
  onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
    setEmail(e.target.value)
  }
/>
</FormControl>
<FormControl>
<TextField
  InputLabelProps={{
    shrink: true,
  }}
  name="pw"
  label="パスワード"
  value={pw}
  onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
    setPW(e.target.value)
  }
/>
</FormControl>

さらに、LoginボタンとRegisterボタンをそれぞれクリックしたときに呼び出されるFirebaseの各処理を記載する。

src/Login.tsx
<Button
variant="contained"
color="primary"
size="small"
onClick={

}
>
{isLogin ? "Login" : "Register"} //LoginとRegisterに応じてボタンの表記を変更する。
</Button>

onClick={}内の場合分け

src/Login.tsx
onClick={
          isLogin
            ? async () => {
                try {
                  await auth.signInWithEmailAndPassword(email, pw);
                  props.history.push("/");
                } catch (e) {
                  console.log(e);
                }
              }
            : async () => {
                try {
                  await auth.createUserWithEmailAndPassword(email, pw);
                  props.history.push("/");
                } catch (e) {
                  console.log(e);
                }
              }
        }

追加で、ユーザがログインと登録を切り替えるようにtoggleボタンを用意します。
このボタンをクリックした際に、state: isLoginを反転させることで、
画面タイトルの表記,Login or Registerボタンの表記,本toggleボタンの表示名の値を変化させます。

src/Login.tsx
<Typography>
<button
  onClick={() => {
    setIsLogin(!isLogin);
  }}
>
  {isLogin ? "Create new account" : "Back to login"}
</button>
</Typography>


SignOut機能の実装

最後に、SignOut機能を実装する。
サインアウトボタンはメイン画面に表示されるので、App.tsxを編集します。

src/App.tsx
import { db, auth } from "./firebase"; //signoutメソッドを呼び出します。
import { ExitToAppOutlined } from "@material-ui/icons"; //Logoutボタン用です。

useEffect(() => { //ここにアクセスした際にuserにデータが格納されていなければログイン画面に遷移させます。(リダイレクト認証)
const unSub = auth.onAuthStateChanged((user) => {
!user && props.history.push("/login");
});
return () => unSub();
}, [props.history]);

<button  // ログアウトボタンをクリックしたときにauthのサインアウトを呼び出して、login画面に遷移させます。
onClick={async () => {
  try {
    await auth.signOut();
    props.history.push("login");
  } catch (e) {
    console.log(e);
  }
}}
>
<ExitToAppOutlined />
</button>

OK

ちゃんとログイン認証処理が走ってます。

t {code: 'auth/wrong-password', message: 'The password is invalid or the user does not have a password.', a: null}
a: null
code: "auth/wrong-password"
message: "The password is invalid or the user does not have a password."
[[Prototype]]: Error

リダイレクト認証も確認済みです。

[HMR] Waiting for update signal from WDS...

Discussion

ログインするとコメントできます