react+Firebase

これまで、Supabaseの活用を中心に、色々と試行してきた、学習記録アプリのチュートリアルですが、今回は、ホスティングのみならず、認証及びDBもFirebaseの機能で実装してみました。そのチュートリアル記事となります。認証は、Firebase Authentication、DBは、Firestore Databaseを活用しています。
内容が長いので、前編・後編と分けてお送りしたいと思います。
今回は前編となります。

前編は、React環境構築、Firebase(プロジェクト・認証・DB)設定、ベースデザイン作成、ログイン・DBデータ取得機能の実装について掲載しています。
後編では、DBデータの新規登録・編集・削除機能、ユーザーサインアップ・パスワードリセット機能の実装について掲載する予定です。

なお、Firebaseの機能実装に当たり、参考にしたサイトは以下のサイトです。

こちらの記事は、少々古い箇所もあるので、適宜、最新の仕様に合わせて作成しています。また、やはりFirebaseの公式ドキュメントもとても重要です。

はじめに

本記事は、Reactの初学者向けのチュートリアルコンテンツです。Reactの基本的な使い方から実際にアプリケーションをデプロイしてユーザーが使えるリリースまでを行えます。また、認証機能やDB処理にFirebaseのサービスを活用しており、バックエンドの要素も包括しています。

React環境は、Vite+TypeScript
CSS+UIツールとしては、Chakra UI、アイコンとして、React Icons
BaaSとして、認証機能及び、DB、ホスティングサービスをGoogleのFirebaseで実現しています。
本記事で、以下の対応が可能です。

  • React+Vite環境の構築
  • TypeScriptのコード記述
  • ChakraUIの導入・利用
  • Reactアイコンの利用
  • useStateによるState管理、Propsの扱い、useEffectの使用
  • React Routerによるルート設定、制御
  • Formイベント、イベントハンドラーの扱い
  • カスタムフックの活用
  • async/awaitによる非同期処理
  • Firebaseのプロジェクト作成、アプリの定義
  • Firebaseの認証設定と、認証制御、セッション管理
  • Firebase、FirestoreDBの環境設定とテーブル処理(表示、登録、更新、削除)
  • GitHubリポジトリの扱い
  • Firebaseホスティングの利用、GitHubと連携した自動デプロイ 等

前編の今回は、Reactプロジェクトの作成・環境構築、Firebaseプロジェクトの作成、認証・DBの設定、FirebaseSDK関連の構築、アプリのベースデザインの作成、Firebase認証のログイン機能・FirestoreDBのデータ取得実装までを掲載しています。

1. 環境構築

まずは開発にあたって必要な環境を構築していきます。Reactプロジェクトの作成・設定、Firebaseプロジェクトの作成・設定などです。

1.1 Reactプロジェクトの作成

まずは、JavaScript実行環境のnode.jsとパッケージマネージャのnpmをインストールします。
(既に環境がある方はスキップしてください)
macでのインストールはbrewを使う場合もありますが、無難にインストーラパッケージをインストールしました。Windowsの方も同じくインストーラからインストールです。

インストール完了したら、ターミナルでnode.jsとnpmのバージョン確認をしましょう。
-vはバージョン確認のコマンドです。

node -v
v20.16.0

npm -v
10.8.3

次にViteを利用してReactプロジェクトを作成します。
ターミナル上で、Reactプロジェクトを作成したいディレクトリに移動し、
npm create vite@latest でプロジェクト作成を実行します。
プロジェクト名は「learning-firebase」で
他の選択肢は下記の通りです。

npm create vite@latest
 Project name:  learning-firebase
 Select a framework:  React
 Select a variant:  TypeScript

これでReactの初期環境構築は完了です。
続けて出てくる内容に従って、環境起動を行います。

cd learning-firebase
npm i
npm run dev
npm i は、プロジェクト作成時に生成された、プロジェクトのライブラリの依存環境が記載されたpackage.jsonに明示されているすべてのパッケージをインストールします。
npm run devは、開発用のサーバの起動を行います。これにより、ブラウザで開発プロジェクトの実行結果を確認出来ます。通常開発サーバは、http://localhost:5173/
で起動されます。

localhost:5173にアクセスすると以下の画面が表示されます

続いて、Chakra UI、React Router、Reactアイコンをインストールします。
サーバを起動したターミナルで「ctrl-c」を入力し一度サーバ停止するか、別のターミナルを開いてlearning-firebaseディレクトリに移動してインストールします。

nnpm i @chakra-ui/react@2.10.3 @emotion/react @emotion/styled framer-motion react-icons react-router-dom

Chakra UIついては、V3が最近リリースされており、何も指定しないと、最新のV3がインストールされます。が、まだドキュメント等整備されておらず、仕様もかなり変わってますので、V2の最新バージョンを利用する形にしています。@chakra-ui/react@2.10.3で、バージョン指定しています。

1.2 Firebaseの初期設定

続いて、Firebaseプロジェクトを作成します。
Firebaseのサイトにアクセスして、プロジェクトを作成していきます。

Firebaseのアカウトをお持ちでない方は、アカウントを作成してください。googleアカウントでサインイン可能です。アカウントを作成し、コンソール画面から、「プロジェクトを作成」をクリックします。

まず、プロジェクトの名前をつけるところからです。ここでは「learning-firebase」としましたが、お好きなプロジェクト名で結構です。入力して、「続行」をクリックします。

次のGoogleアナリティクスは特に必要ありませんので、オフにして、「プロジェクトを作成」をクリックします。

プロジェクト作成の処理画面に遷移し、準備が完了したら、その旨メッセージが表示されますので、「続行」をクリックします。

プロジェクトの概要ページが表示されますので、続いて、アプリの追加を行います。
左から3番目のアイコン、ウェブアプリをクリックします。

ウェブアプリにFirebaseを追加の画面が表示されます。アプリの登録で、ニックネームを付ける必要があるので、任意のニックネームを入力します。
なお、Firebase Hostingの設定オプションがありますが、Hostingは後ほど、設定しますので、ここではスキップします。「アプリを登録」をクリックします。

続いて、Firebase SDKの追加画面が表示されます。そのまま一番下にある、「コンソールに進む」をクリックします。と言いますか、ここの箇所、画面キャプチャし逃したので・・・、ここで実施しなくても後で出来ると言うことで、そのまま進みます。。。

プロジェクトの画面に遷移しますので、左側上部にある、歯車アイコンをクリックします。表示されるメニューのうち、プロジェクトの設定をクリックしてください。

マイアプリの箇所に、登録したウェブアプリのSDKの設定と構成が表示されます。
この情報を使って、ReactプロジェクトへのFirebaseの環境設定を行っていきます。

1.3 Firebase SDK設定

続いて、ReactプロジェクトにFirebase SDKの設定、環境定義をしていきます。
まず、先程、実施したFirebaseのマイアプリに案内のあった、Firebaseパッケージをインストールします。ターミナルで下記を実行します。

npm install firebase


次にVSCode等のエディタでコードを記載していきます。VSCode等のエディタでlearning-firebaseディレクトリを開きます。なお、ローカルで開発される方は、拡張性の高さ、使い勝手の良さ、作業効率などで、VSCodeが圧倒的にお薦めです。

プロジェクトルート直下に.env.localと言うファイルを作成します。これはFirebaseに接続する際に必要となる各種情報を定義するファイルです。

.env.localの中に以下内容を記載します。なお、YOUR-と記載がある箇所は、Firebaseプロジェクトによって各々異なるため、先のFirebaseのSDK設定のページで表示されていた各項目を記載します(apiKey、authDomainなど)。

VITE_FIREBASE_API_KEY="YOUR-apiKey"
VITE_FIREBASE_AUTH_DOMAIN="YOUR-authDomain"
VITE_FIREBASE_PROJECT_ID="YOUR-projectId"
VITE_FIREBASE_STORAGE_BUCKET="YOUR-storageBucket"
VITE_FIREBASE_MESSAGE_SENDER_ID="YOUR-messagingSenderId"
VITE_FIREBASE_APP_ID="YOUR-appId"
なお、参考記事では、REACT_APP_FIREBASE_....
と言う記載になっていますが、Viteを利用する場合は、VITE_FIREBASE_... となりますので、ご注意ください。

続いて、プロジェクトの/srcフォルダ配下に、utilsと言うフォルダを作成の上、firebase.tsと言うファイルを作成します。

firebase.tsに下記コードを記載します。

// /src/utils/firebase.ts

import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';//認証機能のインポート
import { getFirestore } from 'firebase/firestore';//DB機能のインポート

const firebaseConfig = {//.env.localの内容を読み込むよう設定
    apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
    authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
    projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
    storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
    messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGE_SENDER_ID,
    appId: import.meta.env.VITE_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);//認証機能の定義
export const db = getFirestore();//DB機能の定義

このファイルは、Firebaseの処理を行う際にクライアント機能として動作するものとなります。1.2 Firebaseの初期設定の箇所で確認した、SDK情報を基本的に記載してますが、追加で、これから利用する、認証機能のgetAuth、DBのgetFirestoreを追加しています。
また、firebaseConfigの箇所は、先に作成した、.env.localの値を読み込む形です。FirebaseコンソールのSDK情報では、firebaseConfigの箇所に、そのまま各情報を記載する形ですが、このように、.env.localと分離して定義しているのは、セキュリティ的な理由です。機密情報はコード中に埋め込むより、分離して別で定義しておいた方が好ましいです。

このfirebaseConfigの箇所も参考記事とは異なる、Viteを利用する場合の記載の仕方となっていますので、ご注意ください。

ここまでで、ひとまず、Firebase SDK設定は完了です。続いて、Firebaseの認証とDBの設定をしていきます。

1.4 Firebase Authenticationの設定

Firebaseの認証機能、Authenticationの設定を行います。
Firebaseコンソールで左側メニューのAuthenticationをクリックします。

表示される画面の、「始める」をクリックします。

ログインプロバイダの箇所のネイティブのプロバイダから、「メール/パスワード」をクリックします。

右側にある「有効にする」スイッチをオンにし、下にある、「保存」をクリックします。メールリンク(パスワードなしでログイン)はオフのままにします。

続いて、「ユーザー」タブをクリックし、ユーザーを追加します。「ユーザーを追加」をクリックして、表示される画面でメールアドレスとパスワードを入力し、「ユーザーを追加」をクリックすれば登録完了です。なお、架空のメールアドレスでの登録も可能です(下記画像ではそうしてます)。が、今後パスワードリセット等の機能で、メール送信によるURL案内を行いますので、実在・受信可能なメールアドレスの設定をおすすめします。

次に、「テンプレート」タブをクリックし、テンプレートを表示します。ここでは下にある、「テンプレート言語」の箇所を日本語に変更します。その他はそのままとしますが、必要に応じてテンプレート内容をカスタマイズしてください。

以上でAuthenticationの設定は完了です。

1.5 Firestore Databaseの設定

続いて、FirestoreのDB設定を行っていきます。
Firebaseコンソールの左側メニュー、構築の箇所の「Firestore Database」を選択します。

「データベースを作成」をクリックします。

データベースの作成画面で、名前とロケーションを設定します。名前は入力しなくてもDefault設定がありますので大丈夫です。ロケーションはお好きな箇所でいいと思いますが、ここでは、Tokyoとしました。「次へ」をクリックします。

続いて、セキュリティルールの設定画面となります。ここでは「本番環境モード」を選択し、「作成」をクリックします。なお、後ほど、認証ユーザのみに権限を与えるよう変更します。

Databaseのプロビジョニングが実行され、データベースのデータ作成画面に遷移します。
ここで、「コレクションを開始」をクリックします。

コレクションIDを入力します。ここでは「users_learnings」としました。任意で結構です。後ほどの開発ではこのコレクションID宛に処理を行いますのでその点はご留意ください。

最初のドキュメントの追加画面が出てきますので、下記の通り登録します。なお、emailは先のAuthenticationの箇所で追加したユーザーのemailを入力します(下図はあくまでもサンプルです)。
また、ドキュメントIDは、右側にある、自動IDをクリックすると、自動で挿入されます。
timeはnumber型なのでご注意ください。
このまま、保存します。

更にもう一つだけデータを追加しておきます。
下図画面でドキュメントの追加をクリックします。

表示される画面で、下記のように今度はTypeScriptを登録します。
emailは先程登録したものと同じものにしてください。timeは適用な数値で入力します。なお、timeはnumber型なのでご注意ください。
ドキュメントIDは先のものと同様、自動IDを適用します。

続いて、DBに対するセキュリティを設定します。
Firestoreの「ルール」タブの箇所で下図のように、DBの処理権限を認証済ユーザに制限する内容に変更します。

      allow read, write: if false;

      allow read, write: if request.auth != null;

具体的には上記の通り、認証ユーザにread, writeの許可を行うものです。

なお、このルールだと、認証ユーザであれば、どのデータにもアクセス可能な状態となります。更に制限を加えたい場合は、認証ユーザかつ、データの作成ユーザに制限する等の対処が推奨されます。
https://firebase.google.com/docs/rules/basics?hl=ja&authuser=0

ここまでで、ひとまず、Firestore Databaseの設定は完了です。
これで、Firebaseの認証及びDBの準備が出来た状況となります。

2. ベースデザインの作成

React及びFirebaseプロジェクト環境が整いましたので、アプリを開発していきます。今回作成するアプリの構造は以下イメージとなります。

React 学習記録アプリ with Firebase
  • App.tsx
    トップコンポーネントです。
  • components/Login.tsx
    ログイン画面です。ログインしていない場合の初期画面となります。ルーティングは”/login”で設定します。
  • components/Home.tsx
    学習記録を表示するHome画面です。ログイン状態であれば、最初に表示される画面です。ルーティングは”/”で設定します。
  • components/Home(Modal) Edit
    学習記録を編集・更新する画面です。Home.tsxの中でモーダルを採用しています。
  • components/Home(Modal) Delete
    学習記録を削除する画面です。Home.tsxの中でモーダルを採用しています。
  • components/Home(Modal) Entry
    学習記録を新規登録する画面です。Home.tsxの中でモーダルを採用しています。
  • components/Home(Modal) Logout
    ログアウト用のモーダル。Home.tsxの中で実装しています。ログアウトすると、初期画面(ログインページ)に遷移します。
  • components/Register.tsx
    ユーザーサインアップの画面です。ログイン初期画面で新規登録をクリックすると遷移します。ルーティングは”/register”でセットします。
  • components/ResetSend.tsx
    パスワード忘れ等、パスワードをリセットする場合の画面です。入力されたメールアドレス宛にリセット画面URLの案内メールを送信します。ルーティングは”/sendReset”でセットします。
  • components/UpdatePassword.tsx
    ログインしている状態でパスワード変更を行う場合に利用します。ルーティングは”/updatePassword”でセットします。
  • なお、パスワードをリセットの場合は、メールで案内されるのは、Firebase側で持っているUI画面になります。そちらでパスワードリセット処理を行います。

2.1 main.tsx, App.tsxの変更

最初に、/srcにある、main.tsx, App.tsxの修正を行います。
まずは、main.tsxです。

// /src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'//追加
//import './index.css'削除
import App from './App.tsx'
import { ChakraProvider } from '@chakra-ui/react'//追加


createRoot(document.getElementById('root')!).render(
  <StrictMode>
    {/*追加、修正ここから*/}
    <ChakraProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </ChakraProvider>
    {/*追加、修正ここまで*/}
  </StrictMode>,
)

インポート箇所に、react-router-domと、@chakra-ui/reactのインポートを追加します。また、’./index.css’は利用しませんので、削除するか、コメントアウトします。

次にJSXの箇所です。ReactではHTMLの部分とjsの部分を1つのファイルに書くことができます。これは、JSXと呼ばれます。

<App />の箇所を、<ChakraProvider>と<BrowserRouter>でラップします。<ChakraProvider>はChakra UIを本アプリで利用するために、最も上位の階層のmain.tsxに設置してます。<BrowserRouter>はReact Routerを利用するために設置しています。

React Routerは、複数のページを持つReactアプリケーションを構築する際に利用されるライブラリです。React Routerを使うことで、ユーザーの操作に応じて表示内容を変更したり、URLにパラメータやクエリを含めて表示内容を変更したりすることができます。本アプリもReact Routerにでページ遷移、URL遷移を実装します。

次に、App.tsxです。デフォルトで色々記述されてますが、一旦すべて削除します。その上で、下記の内容とします。

// /src/App.tsx
function App() {

  return (
    <>

    </>
  )
}

export default App

中身は空っぽです。ひとまず、枠だけとする形です。

2.2 Login.tsxの作成

続いて、ログインコンポーネントとなるLogin.tsxを作成します。/src配下に新たにcomponentsフォルダを作成し、その中に、Login.tsxと言うファイルを作成します。

Login.tsxに下記コードを記載します。

// /src/components/Login.tsx

import { Flex } from "@chakra-ui/react";

const Login = () => {
    return (
        <Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>
            <h1>Login</h1>
        </Flex>
    )
}
export default Login;

中身は、単純に「Login」のみ表示する内容ですが、Chakra UI の<Flex>を利用しています。
<Flex>はその名の通り、CSSで言うflexです。この為、冒頭、Chakra UIのインポートを行っています。

なお、このimport文は、以前ご紹介した通り、VSCodeであれば自動補完をしてくれます。

次にLoginコンポーネントをApp.tsxに配置してみます。

// /src/App.tsx

import Login from "./components/Login"//Loginコンポーネントのインポート

function App() {

    return (
        <>
            <Login />{/*Loginコンポーネントの配置*/}
        </>
    )
}

export default App

この状態で、開発サーバーを停止している場合は、ターミナルにnpm run devコマンドを投入しサーバーを起動、http://localhost:5173/にアクセスしてみます。
下記のようにLoginの文字だけ表示され、Loginコンポーネントが表示されていることが分かります。

続けて、Loginコンポーネントのレイアウトや部品を配置していきます。
Login.tsxを以下のように修正・追加します。下記はコード全体です。

// /src/components/Login.tsx

import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Stack } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiLockPasswordFill } from "react-icons/ri";

const Login = () => {

    return (
        <>
            <Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>
                <Card size={{ base: 'sm', md: 'lg' }} p={4}>
                    <Heading size='md' textAlign='center'>ログイン</Heading>
                    <CardBody>
                        <form onSubmit={() => { }}//仮で関数設置(処理内容は未記載)
                        >
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <FaUserCheck color='gray' />
                                </InputLeftElement>
                                <Input
                                    autoFocus
                                    type='email'
                                    placeholder='メールアドレスを入力'
                                    name='email'
                                    required
                                    mb={2}
                                    onChange={() => { }}//仮で関数設置(処理内容は未記載)
                                />
                            </InputGroup>
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <RiLockPasswordFill color='gray' />
                                </InputLeftElement>
                                <Input
                                    type='password'
                                    placeholder='パスワードを入力'
                                    name='password'
                                    required
                                    mb={2}
                                    onChange={() => { }}//仮で関数設置(処理内容は未記載)
                                />
                            </InputGroup>
                            <Box mt={4} mb={2} textAlign='center'>
                                <Button
                                    //isLoading={loading}//ローディング状態の定義、後ほど設定するので、一旦コメントアウト
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    type='submit' colorScheme='green' width='100%' mb={2}>ログイン</Button>
                                <Button colorScheme='green' width='100%' variant='outline' onClick={() => { }}
                                >新規登録</Button>
                            </Box>
                            <Box mt={4} mb={2} textAlign='center'>
                                <Stack spacing={3}>
                                    <Button
                                        colorScheme='green'
                                        width='100%'
                                        variant='ghost'
                                        onClick={() => { }}>パスワードをお忘れですか?</Button>
                                </Stack>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}
export default Login;

解説していきます。
まず、冒頭のインポート、型定義、コンポーネント定義の箇所です。 

// /src/components/Login.tsx

import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Stack } from "@chakra-ui/react";//Chakra UIのパーツのインポート
import { FaUserCheck } from "react-icons/fa";//ユーザーアイコンのインポート
import { RiLockPasswordFill } from "react-icons/ri";//パスワードアイコンのインポート

const Login = () => {

    return (

冒頭、インポートの箇所は、Chakra UIで必要となるコンポーネントのインポート、入力エリア(Input)に利用するアイコンのreact-iconsからのインポートを行っています。

その次のコンポーネント定義(const Login = () => { )の下は、returnでJSXを返しています。

続いて、JSXの箇所です。

return (
        <>
            <Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>{/*Flex適用*/}
                <Card size={{ base: 'sm', md: 'lg' }} p={4}>{/*Chakra UIのCard適用*/}
                    <Heading size='md' textAlign='center'>ログイン</Heading>
                    <CardBody>
                        <form onSubmit={() => { }}//仮で関数設置(処理内容は未記載)
                        >
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <FaUserCheck color='gray' />
                                </InputLeftElement>
                                <Input
                                    autoFocus//自動でフォーカスをあてる
                                    type='email'
                                    placeholder='メールアドレスを入力'
                                    name='email'
                                    required
                                    mb={2}
                                    onChange={() => { }}//仮で関数設置(処理内容は未記載)
                                />
                            </InputGroup>
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <RiLockPasswordFill color='gray' />
                                </InputLeftElement>
                                <Input
                                    type='password'
                                    placeholder='パスワードを入力'
                                    name='password'
                                    required
                                    mb={2}
                                    onChange={() => { }}//仮で関数設置(処理内容は未記載)
                                />
                            </InputGroup>
                            <Box mt={4} mb={2} textAlign='center'>
                                <Button
                                    //isLoading={loading}//ローディング状態の定義、後ほど設定するので、一旦コメントアウト
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    type='submit' colorScheme='green' width='100%' mb={2}>ログイン</Button>
                                <Button colorScheme='green' width='100%' variant='outline' onClick={() => { }}
                                >新規登録</Button>
                            </Box>
                            <Box mt={4} mb={2} textAlign='center'>
                                <Stack spacing={3}>
                                    <Button
                                        colorScheme='green'
                                        width='100%'
                                        variant='ghost'
                                        onClick={() => { }}>パスワードをお忘れですか?</Button>
                                </Stack>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )

コード中に色々コメントで解説しています。
<Flex><Card>等、Chakra UIのコンポーネントを配置の上、<Form>設置、<Input>にてメールアドレス、パスワード入力エリアを設けています。

その他、後ほど具体的な関数処理を定義する箇所はこの時点では空の関数 () => {} で仮設置しています。
formのsubmit時動作する、onSubmit、Inputエリアの入力時の動作、onChange、ボタンクリック時の動作、onClickなどです。

この時点で以下画面が表示されます。メールアドレスとパスワードは、requiredで定義してますので、空のままログインボタンをクリックすると、ブラウザの機能でエラー表示します。

2.3 Home.tsxの作成

続いて、ログイン後に表示される、Homeコンポーネントを作成します。
componentsフォルダに、Home.tsxと言うファイルを作成します。

Home.tsxに下記コードを記載します。

// /src/components/Home.tsx

import { Box, Button, Card, CardBody, Flex, Heading, Stack, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";

const Home = () => {
    return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card size={{ base: 'sm', md: 'lg' }}>
                    <Box textAlign='center' mb={2} mt={10}>
                        ようこそtest@test.com さん
                    </Box>
                    <Heading size='md' textAlign='center'>Learning Records</Heading>
                    <CardBody>
                        <Box textAlign='center'>
                            学習記録
                            <TableContainer>
                                <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                                    <Thead>
                                        <Tr>
                                            <Th>学習内容</Th>
                                            <Th>時間()</Th>
                                            <Th></Th>
                                            <Th></Th>
                                        </Tr>
                                    </Thead>
                                    <Tbody>
                                        <Tr>
                                            <Td>React</Td>
                                            <Td>10</Td>
                                            <Td>
                                                <Button variant='ghost'><FiEdit color='black' /></Button>
                                            </Td>
                                            <Td>
                                                <Button variant='ghost'><MdDelete color='black' /></Button>
                                            </Td>
                                        </Tr>
                                    </Tbody>
                                </Table>
                            </TableContainer>
                        </Box>

                        <Box p={5}>
                            <div>合計学習時間:10</div>
                        </Box>

                        <Box p={25}>
                            <Stack spacing={3}>
                                <Button
                                    colorScheme='green'
                                    variant='outline'
                                    onClick={() => { }}
                                >新規データ登録
                                </Button>
                            </Stack>
                        </Box>
                        <Box px={25} mb={4}>
                            <Stack spacing={3}>
                                <Button
                                    width='100%'
                                    variant='outline'
                                    onClick={() => { }}
                                >ログアウト</Button>
                            </Stack>
                        </Box>
                        <Box px={25} mb={4}>
                            <Stack spacing={3}>
                                <Button
                                    width='100%'
                                    variant='outline'
                                    onClick={() => { }}
                                >パスワード更新</Button>
                            </Stack>
                        </Box>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}
export default Home;


解説します。
まず、冒頭のインポートの箇所です。

// /src/components/Home.tsx

import { Box, Button, Card, CardBody, Flex, Heading, Stack, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";//Chakra UIのパーツのインポート
import { FiEdit } from "react-icons/fi";//編集アイコンのインポート
import { MdDelete } from "react-icons/md";//削除アイコンのインポート

const Home = () => {

Chakra UIで必要となるコンポーネントのインポートを行っています。その他、編集用のアイコン、削除用のアイコンをreact-iconsからインポートしています。

次にJSXの箇所です。

// /src/components/Home.tsx

return (
    <>
        <Flex alignItems='center' justify='center' p={5}>{/*Flex適用*/}
            <Card size={{ base: 'sm', md: 'lg' }}>{/*Chakra UIのCard適用*/}
                <Box textAlign='center' mb={2} mt={10}>
                    ようこそtest@test.com さん{/*仮記載、後ほど定数に変更*/}
                </Box>
                <Heading size='md' textAlign='center'>Learning Records</Heading>
                <CardBody>
                    <Box textAlign='center'>
                        学習記録
                        <TableContainer>
                            <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                                <Thead>
                                    <Tr>
                                        <Th>学習内容</Th>
                                        <Th>時間()</Th>
                                        <Th></Th>
                                        <Th></Th>
                                    </Tr>
                                </Thead>
                                <Tbody>
                                    <Tr>
                                        <Td>React</Td>{/*仮記載、後ほど定数に変更*/}
                                        <Td>10</Td>{/*仮記載、後ほど定数に変更*/}
                                        <Td>
                                            <Button variant='ghost'><FiEdit color='black' //編集用アイコン
                                            /></Button>
                                        </Td>
                                        <Td>
                                            <Button variant='ghost'><MdDelete color='black' //削除用アイコン
                                            /></Button>
                                    </Tr>
                                </Tbody>
                            </Table>
                        </TableContainer>
                    </Box>

                    <Box p={5}>
                        <div>合計学習時間:10</div>{/*仮記載、後ほど定数に変更*/}
                    </Box>

                    <Box p={25}>
                        <Stack spacing={3}>
                            <Button
                                colorScheme='green'
                                variant='outline'
                                onClick={() => { }}//仮で関数設置(処理内容は未記載)
                            >新規データ登録
                            </Button>
                        </Stack>
                    </Box>
                    <Box px={25} mb={4}>
                        <Stack spacing={3}>
                            <Button
                                width='100%'
                                variant='outline'
                                onClick={() => { }}//仮で関数設置(処理内容は未記載)
                            >ログアウト</Button>
                        </Stack>
                    </Box>
                    <Box px={25} mb={4}>
                        <Stack spacing={3}>
                            <Button
                                width='100%'
                                variant='outline'
                                onClick={() => { }}//仮で関数設置(処理内容は未記載)
                            >パスワード更新</Button>
                        </Stack>
                    </Box>
                </CardBody>
            </Card>
        </Flex>
    </>
)

コード中にコメントで説明を記載していますが、<Flex><Card>等、Chakra UIのコンポーネントを配置してます。学習記録の表示は、<table>を利用しています。
ユーザID(email)や、学習情報等、後ほど、定数に置き換える箇所はそのようにコメント記載してます。
また、Login.tsx同様、後ほど具体的な関数処理を定義する箇所はこの時点では空の関数 () => {} で仮設置しています。

続いて、App.tsxにHomeコンポーネントとLoginコンポーネントのルーティング設定を行います。
App.tsxに以下変更を追加します。

// /src/App.tsx

import { Route, Routes } from "react-router-dom"//React Routerインポート追加
import Home from "./components/Home"//Homeコンポーネント追加
import Login from "./components/Login"

function App() {

  return (
    <>
      <Routes>
        <Route path="/" element={<Home />}//Homeコンポーネントのルーティング設定
        />
        <Route path="/login" element={<Login />}//Loginコンポーネントのルーティング設定
        />
      </Routes>
    </>

  )
}

export default App

冒頭、インポートの箇所に、React Routerのインポート、Homeコンポーネントのインポートを追加しています。また、JSXの箇所でルーティングの設定をしてます。
<Routes>で囲まれた箇所に<Route path=’設定したいパス’ element={読み込むコンポーネント+Props(コンポーネントに渡すデータ)がある場合はそれを記載} />のような書き方をします。
Homeはサイトルート”/”で設定、Loginは、”/login”で設定しています。

この状態で、http://localhost:5173/にアクセスしてみます。Homeコンポーネントを”/”でルート設定してますので、下図のように作成したHomeコンポーネントが表示されると思います。

また、http://localhost:5173/login にアクセスすると、先に作成したログイン画面が表示されます。

3. ログイン機能の作成

続いて、ログイン機能を開発していきます。今回、Firebaseの認証及びDBの処理は、カスタムフックを活用して実装したいと思います。フック(Hooks)はコンポーネントから呼び出し利用可能なReactの様々な機能です。

カスタムフックは、その名の通り、独自に自分で作成するオリジナルフックです。コンポーネントの複雑化を防ぐ、Viewとロジックを分離することが出来る、機能を再利用できる等のメリットがあります。

3.1 カスタムフックの作成

それでは、カスタムフックを作成していきます。/src配下にhooksと言うフォルダを作成します。その中にuseFirebase.tsと言うファイルを作成します。

カスタムフックの名前は、useで始める必要があります。React はカスタムフックもルールを違反してるかどうかを自動でチェックしますが、この命名規則を守らないとカスタムフックかどうか判別できなくなりチェックもできなくなってしまいます。

useFirebase.tsに以下のコードを記載します。

// /src/hooks/useFirebse.ts
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "../utils/firebase";

type UseFirebase = () => {
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
    email: string;
    setEmail: React.Dispatch<React.SetStateAction<string>>;
    password: string;
    setPassword: React.Dispatch<React.SetStateAction<string>>;
    handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>;
}

export const useFirebase: UseFirebase = () => {

    const [loading, setLoading] = useState(false);
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const navigate = useNavigate()
    const toast = useToast()

    ////Authentication
    //ログイン処理
    const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setLoading(true);
        try {
            const userLogin = await signInWithEmailAndPassword(auth, email, password);
            console.log("User Logined:", userLogin);
            toast({
                title: 'ログインしました',
                position: 'top',
                status: 'success',
                duration: 2000,
                isClosable: true,
            })
            navigate('/')
        }
        catch (error) {
            console.error("Error during sign up:", error);
            toast({
                title: 'ログインに失敗しました',
                description: `${error}`,
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
        }
        finally {
            setLoading(false);
        }
    };

    return {
        loading,
        setLoading,
        email,
        setEmail,
        password,
        setPassword,
        handleLogin
    }
}


解説していきます。
まずは、冒頭のインポート箇所、型定義とコンポーネント定義の箇所です。

// /src/hooks/useFirebse.ts
import { useState } from "react";//useStateのインポート
import { useNavigate } from "react-router-dom";//React RouterのuseNavigateのインポート
import { useToast } from "@chakra-ui/react";//Chakra UIのToast機能のインポート
import { signInWithEmailAndPassword } from "firebase/auth";//FirebaseSDKのemailログイン機能のインポート
import { auth } from "../utils/firebase";//Firebaseクライアントから認証機能のインポート

type UseFirebase = () => {//useFirebasの型定義、関数として型定義 (() => {})
    loading: boolean;//真偽値
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;//React setStateの型
    email: string;//文字列
    setEmail: React.Dispatch<React.SetStateAction<string>>;//React setStateの型
    password: string;//文字列
    setPassword: React.Dispatch<React.SetStateAction<string>>;//React setStateの型
    handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>;//関数handleLoginの型
}

export const useFirebase: UseFirebase = () => {//コンポーネントの定義、型はUseFirebaseとして設定

    const [loading, setLoading] = useState(false);//ローディング状態を管理するstateの定義
    const [email, setEmail] = useState('');//emailを管理するstateの定義
    const [password, setPassword] = useState('');//passwordを管理するstateの定義
    const navigate = useNavigate()//React RouterのNavigate機能を利用
    const toast = useToast()//Chakura UIのToastの利用

インポート部分は、Reactの主要フックuseStateのインポート、React RouterのuseNavigate、Chakra UIのToastフックのインポートを行ってます。また、その他、FirebaseSDKのメールアドレスとパスワードで認証するログイン機能のインポート、1.3章で作成したFirebaseクライアント機能から認証処理のインポートを実施してます。

続いて型定義の箇所です。
useFirebaseコンポーネントの型をUseFirebaseとして定義しています。関数として型定義の為、 (() => {})の記載方式となっています。内容は、後ほど説明するuseStateで利用するstateの型定義及びsetStateの型定義を行っています。なお、setStateの型は、React.Dispatch<React.SetStateAction<stateの型>>と言う記載になります。これはこういうものと理解頂ければと思います。
なお、VSCodeであれば、例えば、その下の方で記載している、const [loading, setLoading] の箇所でカーソルをsetLoadingの上に乗せると型情報をポップアップで表示してくれます。それをそのままコピーして貼り付ければOKです。handleLoginもその要領で型定義しています。

次にコンポーネントの定義とフックの定義です。
コンポーネントはconst useFirebase: UseFirebaseと言う形で、その前に定義したUseFirebaseの型を持つコンポーネントとして記載しています。その前にexportを付与して、他のコンポーネントにインポート可能な状態にしています。
フックについては、useStateの定義を3つしてます。loading, email, passwordです。それぞれの説明はコードに記載したコメント通りです。

useStateは、const [state, setState] と、値とそれを変更操作するためのset関数の組み合わせで、慣例的にset関数はset+最初大文字の値名とする事が多い(上記でいうと、state, setState)です。

次に、React Routerの機能のuseNavigateの設定をしています。useNavigateはページ遷移をする際に使用されます。navigate()の引数に遷移先のパスを渡すことでページを遷移する事ができます。

最後は、Chakra UIのトースト機能を利用する、useToastを設定しています。

続いて、ログイン処理の箇所について説明します。

// /src/hooks/useFirebse.ts

////Authentication
//ログイン処理
const hhandleLogin = async (e: React.FormEvent<HTMLFormElement>) => {//async/awaitによる非同期通信、React.FormEventによるイベントの型
    e.preventDefault();// submitイベントの本来の動作を抑止
    setLoading(true);//ローディングをローディング状態に
    try {
        const userLogin = await signInWithEmailAndPassword(auth, email, password);//Firebase SDKによるログイン処理、authは、firebaseクライアントで定義した引数
        console.log("User Logined:", userLogin);
        toast({//処理が正常終了すれば、Chakra UIのToastを利用し、ログイン成功メッセージを表示
            title: 'ログインしました',
            position: 'top',
            status: 'success',
            duration: 2000,
            isClosable: true,
        })
        navigate('/')//ログイン成功後、Home画面('/')に遷移
    }
    catch (error) {//エラー時は、Chakra UIのToastを利用し、エラーメッセージ表示
        console.error("Error during sign up:", error);
        toast({
            title: 'ログインに失敗しました',
            description: `${error}`,
            position: 'top',
            status: 'error',
            duration: 2000,
            isClosable: true,
        })
    }
    finally {
        setLoading(false);//最終処理ととして、ローディング状態を解除
    }
};

ログイン処理の関数をhandleLoginとして定義しています。このhandleLoginは、後ほど、Loginコンポーネントにて実装します。

内容ですが、非同期通信のasync/awaitで、処理を始めます。

その下の、e.preventDefault()はform処理の際によく使われるのですが、formの性質として、formの送信先が自身のURLの場合にリロードを繰り返す動きをします。これが行われると正常に処理されない為、その抑止の為のものです。

続いて、ローディングをローディング状態にセットします。
次にFirebase SDKにより、auth, email, passwordを引数にログイン認証処理を実行しています。
ログインが正常終了した場合は、Chakra UIのトースト機能で、成功メッセージを表示。その後、Homeコンポーネントである’/’に遷移させています。

エラー発生した場合は、同様にChakra UIのトースト機能で、エラーメッセージ表示。最後はローディング状態を解除して終了です。

最後にreturn文です。

// /src/hooks/useFirebse.ts

return {//他コンポーネントに渡すProps定義
    loading,
    setLoading,
    email,
    setEmail,
    password,
    setPassword,
    handleLogin
}

他コンポーネントに渡すデータ、Propsの定義をしています。各state及びログイン処理を行う、handleLoginを渡す形です。

3.2 カスタムフックの実装

次に作成したカスタムフックをLoginコンポーネントで実装します。
Login.tsxの内容を以下に記載します。コード全文です。

// /src/components/Login.tsx

import { Box, Button, Card, CardBody, Flex, Heading, Input, InputGroup, InputLeftElement, Stack } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiLockPasswordFill } from "react-icons/ri";
import { useFirebase } from "../hooks/useFirebase";//カスタムフックのインポート

const Login = () => {
    const { loading, email, setEmail, password, setPassword, handleLogin } = useFirebase()//カスタムフック、useFirebaseの定義

    return (
        <>
            <Flex justifyContent='center' boxSize='fit-content' mx='auto' p={5}>
                <Card size={{ base: 'sm', md: 'lg' }} p={4}>
                    <Heading size='md' textAlign='center'>ログイン</Heading>
                    <CardBody>
                        <form onSubmit={handleLogin}//Form、submit時、useFirebaseによる、handleLogin実行
                        >
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <FaUserCheck color='gray' />
                                </InputLeftElement>
                                <Input
                                    autoFocus
                                    type='email'
                                    placeholder='メールアドレスを入力'
                                    name='email'
                                    value={email}//valueを追加
                                    required
                                    mb={2}
                                    onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}//Inputフィールドに入力された値を、setEmailでemaiステートに格納
                                />
                            </InputGroup>
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <RiLockPasswordFill color='gray' />
                                </InputLeftElement>
                                <Input
                                    type='password'
                                    placeholder='パスワードを入力'
                                    name='password'
                                    value={password}//valueを追加
                                    required
                                    mb={2}
                                    onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}//Inputフィールドに入力された値を、setPasswordでpasswordステートに格納
                                />
                            </InputGroup>
                            <Box mt={4} mb={2} textAlign='center'>
                                <Button
                                    isLoading={loading}//ローディング状態をuseFirebaseより取得し、ローディング中は、スピナーを表示
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    type='submit' colorScheme='green' width='100%' mb={2}>ログイン</Button>
                                <Button colorScheme='green' width='100%' variant='outline' onClick={() => { }}
                                >新規登録</Button>
                            </Box>
                            <Box mt={4} mb={2} textAlign='center'>
                                <Stack spacing={3}>
                                    <Button
                                        colorScheme='green'
                                        width='100%'
                                        variant='ghost'
                                        onClick={() => { }}>パスワードをお忘れですか?</Button>
                                </Stack>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}
export default Login;

解説していきます。
冒頭のインポートは、新たに、作成したカスタムフック、useFirebaseのインポートを追加しています。その下、コンポーネントの定義の後に、useFirebaseの定義をしています。
loading, email, setEmail, password, setPassword, handleLogin をuseFirebaseで利用する宣言をしています。

JSXの箇所は、コメント記載通りですが、下記の処理を追加しています。

  • formサブミット時に、useFirebaseのhandleLoginを実行し、ログイン処理を行う。下記InputのonChangeにより値がセットされたemail、passwordステートで認証を実施
  • Inputエリアにvalueを追加し、email、passwordステートの値を渡す形に変更
  • Inputにデータが入力されると、onChangeでsetState関数を実行し、それぞれ、email、passwordステートに値をセット
  • ログインボタンの箇所に、ローディング中であれば、スピナーを表示するよう、loadingの値を取得

その下の、「新規登録」「パスワードをお忘れですか?」はまだ空の状態です。後ほど、実装していきます。

ここまでの内容で、下図のように、ログインが正しく行われれば、Home画面に遷移する動きが実現できます。

ログイン処理

ログイン処理の内容は、コンソールログにも出力するようにしてますので、適宜、ブラウザの検証ツールのコンソールで確認してみてください。

4. DBデータの取得

ユーザ認証、ログイン処理が出来るようになりましたので、次にFirestore DBからデータを取得し、Homeコンポーネントで表示するようにしていきます。現時点はHomeコンポーネントは固定値を表示している状態ですが、これをDBからデータ取得し表示する形に変更します。

4.1 型定義ファイルの作成

DBデータ取得実装にあたり、DBに格納した学習記録の型定義ファイルを独立したコンポーネントで作成します。これは別ファイルを作らなければならないと言うわけではありませんが、型定義が複雑になってくる、あるいは、各コンポーネントで共通的に使うと言うケースが多い場合は、型定義専用のコンポーネントを作成するのが合理的です。

/src配下に新たにtypesと言うフォルダを作成します。その中にstudyData.tsと言うファイルを作成します。

studyData.tsに以下コードを記載します。Fisetoreのコレクション「users_learnings」で格納されているデータ項目を定義しています。

// /src/types/studyData.ts

export type StudyData = {
    id: string,
    title: string,
    time: number,
}

4.2 useFirebaseの変更

続いて、カスタムフックのuseFirebase.tsに、新たにFirestoreDBのデータ取得機能と、ユーザーセッション監視の機能を追加します。1.5章で、FirestoreDBのデータ取得は、認証ユーザーじゃないと許可しない設定としました。この為、ユーザーセッション状態を取得しログイン中であれば、そのユーザー情報を取得するようにします。

以下は、変更したuseFirebase.tsのコード全文です。

// /src/hooks/useFirebse.ts
import { useEffect, useState } from "react";//useEffect追加
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { signInWithEmailAndPassword, User } from "firebase/auth";// User追加
import { collection, getDocs, query, where } from "firebase/firestore";//firestore関連機能追加
import { auth, db } from "../utils/firebase";//db追加
import { StudyData } from "../types/studyData";//型定義、StudyData追加

type UseFirebase = () => {
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
    email: string;
    setEmail: React.Dispatch<React.SetStateAction<string>>;
    password: string;
    setPassword: React.Dispatch<React.SetStateAction<string>>;
    handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
    user: User | null; // 追加、FirebaseSDKによるUser型またはNull
    setUser: React.Dispatch<React.SetStateAction<User | null>>;//追加
    learnings: StudyData[];//追加、FirestoreDBから取得する学習記録の配列、StudyDataの型データによる配列
    setLearnings: React.Dispatch<React.SetStateAction<StudyData[]>>;//追加
    fetchDb: (data: string) => Promise<void>//追加
    calculateTotalTime: () => number//追加
}

export const useFirebase: UseFirebase = () => {
    const [loading, setLoading] = useState(false);
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [user, setUser] = useState<User | null>(null); // セッションユーザ情報のステート追加
    const [learnings, setLearnings] = useState<StudyData[]>([]);//学習記録データのステート追加
    const navigate = useNavigate()
    const toast = useToast()

    ////Authentication
    //ログイン処理
    const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setLoading(true);
        try {
            const userLogin = await signInWithEmailAndPassword(auth, email, password);
            console.log("User Logined:", userLogin);
            toast({
                title: 'ログインしました',
                position: 'top',
                status: 'success',
                duration: 2000,
                isClosable: true,
            })
            navigate('/')
        }
        catch (error) {
            console.error("Error during sign up:", error);
            toast({
                title: 'ログインに失敗しました',
                description: `${error}`,
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
        }
        finally {
            setLoading(false);
        }
    };

    //追加、ユーザがセッション中か否かの判定処理
    useEffect(() => {
        const unsubscribed = auth.onAuthStateChanged((user) => {
            setUser(user); // userはnullかUserオブジェクト
            if (user) {
                setEmail(user.email as string)
             } else {
            navigate("/login");//userがセッション中でなければ/loginに移動
             }
        });
        return () => {
            unsubscribed();
        };
    }, [user]);

    ////Firestore 
    //追加、Firestoreデータ取得
    const fetchDb = async (data: string) => {
        setLoading(true);
        try {
            const usersCollectionRef = collection(db, 'users_learnings');
            const q = query(usersCollectionRef, where('email', '==', data)); // ログインユーザーのemailでフィルタ
            const querySnapshot = await getDocs(q);
            const fetchedLearnings = querySnapshot.docs.map((doc) => ({
                ...doc.data(),
                id: doc.id,
            } as StudyData));// Firebaseから取得したデータを`StudyData`型に明示的に変換
            setLearnings(fetchedLearnings); // 正しい型でセット
        }
        catch (error) {
            console.error("Error getting documents: ", error);
        }
        finally {
            setLoading(false);
        }
    }

    ////Others
    //追加、学習時間合計
    const calculateTotalTime = () => {
        return learnings.reduce((total, learning) => total + learning.time, 0);
    };

    return {
        loading,
        setLoading,
        email,
        setEmail,
        password,
        setPassword,
        handleLogin,
        user,//追加
        setUser,//追加
        learnings,//追加
        setLearnings,//追加
        fetchDb,//追加
        calculateTotalTime//追加
    }
}


解説していきます。まず、冒頭のインポート、型定義、コンポーネント及びフックの定義です。

// /src/hooks/useFirebse.ts
import { useEffect, useState } from "react";//useEffect追加
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { signInWithEmailAndPassword, User } from "firebase/auth";// User追加
import { collection, getDocs, query, where } from "firebase/firestore";//firestore関連機能追加
import { auth, db } from "../utils/firebase";//db追加
import { StudyData } from "../types/studyData";//型定義、StudyData追加

type UseFirebase = () => {
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
    email: string;
    setEmail: React.Dispatch<React.SetStateAction<string>>;
    password: string;
    setPassword: React.Dispatch<React.SetStateAction<string>>;
    handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
    user: User | null; // 追加、FirebaseSDKによるUser型またはNull
    setUser: React.Dispatch<React.SetStateAction<User | null>>;//追加
    learnings: StudyData[];//追加、FirestoreDBから取得する学習記録の配列、StudyDataの型データによる配列
    setLearnings: React.Dispatch<React.SetStateAction<StudyData[]>>;//追加
    fetchDb: (data: string) => Promise<void>//追加
    calculateTotalTime: () => number//追加
}

export const useFirebase: UseFirebase = () => {
    const [loading, setLoading] = useState(false);
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [user, setUser] = useState<User | null>(null); // セッションユーザ情報のステート追加
    const [learnings, setLearnings] = useState<StudyData[]>([]);//学習記録データのステート追加
    const navigate = useNavigate()
    const toast = useToast()

インポートの箇所は、Reactのフック、useEffectを追加しています。useEffectは、コンポーネントマウント時、stateの更新時など、特定の条件下で処理(副作用 =effect)を行うものです。

続いて、FirebaseSDKのfirebase/authから、新たにUserをインポートしてます。また、Firestoreの処理を行いますので、firebase/firestoreから利用する機能を追加してます。
それと、Firebaseクライアント(/src/utils/firabase.ts)からdb処理の追加、4.1章で作成した型定義、StudyDataをインポートしてます。

次の型定義ですが、新たに追加する、ステートや関数の型定義を追加しています。新たに追加しているuserは、FirebaseSDKの持つUserと言う型、もしくは(初期値は)nullの形です。
learningsは、StudyDataの型定義を持つ配列として定義しています。その他、ステートのsetStateの型と今回追加した関数の型定義を追加しています。

その下、コンポネント定義(export const useFirebase: UseFirebase = () => {)の次のフックの定義箇所に、新しいuseState定義、userとlearningsを追加しています。userは、セッション中のユーザー情報を格納するステート、learningsは、学習記録を格納するステートです。

続いて、追加した関数の箇所を見ていきます。まずは、useEffectによる、ユーザーセッション情報の取得処理です。

// /src/hooks/useFirebse.ts

//追加、ユーザがセッション中か否かの判定処理
    useEffect(() => {
        const unsubscribed = auth.onAuthStateChanged((user) => {
        //FirebaseSDKのonAuthStateChangedメッソドによるユーザーセッション情報取得
            setUser(user); //取得したユーザー情報をsetUserでuserステートに格納
            if (user) {//セッション中のユーザーがあれば
                setEmail(user.email as string)//user情報の中のemailデータをsetEmailでemailステートにセット
             } else {
            navigate("/login");//userがセッション中でなければ/loginに移動
             }
        });
        return () => {
            unsubscribed();//unsubscribed()を実行
        };
    }, [user]);//user状態に変化があった時に実行

const unsubscribedで、FirebaseSDKのonAuthStateChangedメッソドによるユーザーセッション情報取得をしています。

取得したユーザー情報をsetUserでuserステートに格納しています。また、ユーザーセッション情報があれば、setEmailでユーザ情報中のemail情報をemailステートに格納しています。この際、user.email as stringと言う書き方をしていますが、これはuserが、型定義上nullも取りうるため、stringとして扱うことを明示しています(こうしないと、TypeScriptの型エラーが出ます)。
また、userが存在しない=ログイン中では無い場合は、useNavigateで/loginに遷移するようにしています。

その上で、useEffectのreturnとして、unsubscribed()を実行しています。また、useEffectの依存関数の箇所は、[user]と定義しており、user状態に変化があった際に実行する形となっています。

次に、FirestoreDBからのデータ取得処理です。

// /src/hooks/useFirebse.ts

////Firestore 
//追加、Firestoreデータ取得
const fetchDb = async (data: string) => {//async/awaitで処理実施
    setLoading(true);//ローディングをローディング中にセット
    try {
        const usersCollectionRef = collection(db, 'users_learnings');//取得するデータ(コレクション)の定義、users_learningsから取得
        const q = query(usersCollectionRef, where('email', '==', data)); // emailのデータが、ログインユーザーのemailとマッチするするものを取得
        const querySnapshot = await getDocs(q);//querySnapshot に取得したデータを格納
        const fetchedLearnings = querySnapshot.docs.map((doc) => ({//querySnapshotに格納されたデータをmapメソッドで展開し、dod.data()に格納。
            ...doc.data(),
            id: doc.id,
        } as StudyData));// Firebaseから取得したデータは型情報がないため、`StudyData`型に明示的に変換
        setLearnings(fetchedLearnings); // 先に処理したfetchedLearningsを、setLearningsで、learningsに`StudyData`型でセット
    }
    catch (error) {
        console.error("Error getting documents: ", error);//エラー発生の場合、エラー出力
    }
    finally {
        setLoading(false);//最後に、ローディング状態を解除
    }
}

DBデータ取得処理をfetchDbとして設定しています。async/awaitの非同期処理で実装しています。
まず、loadingステートをtrueにし、ローディング中にセットしてます。
続いての処理は、FirebaseSDKのfirebase/firestoreのメソッド群を使っています。collection, getDocs, query, where です。

処理の流れはコード中に記載したコメント通りです。
FirestoreDBから取得したデータをmapで展開しfetchedLearningsに整理して代入しています。この際、DBから取得したデータは型情報を持たない為、fetchedLearningsに代入時、型情報StudyDataを付与しています。そしてfetchedLearningsをsetLearningsでlearningsステートに格納しています。

最後にローディング状態を解除して終了です。

次にもう一つ関数を追加していますので、そちらの説明です。

// /src/hooks/useFirebse.ts

////Others
//追加、学習時間合計
const calculateTotalTime = () => {//学習記録の時間の合計を算出
    return learnings.reduce((total, learning) => total + learning.time, 0);//JavaScriptのreduce()メソッドでトータルを計算
};

こちらは取得した学習記録の総時間を算出するものです。JavaScriptの関数reduceを利用して、learningsに格納された、timeの値の総和を出しています。

続いて最後のreturnの箇所です。

// /src/hooks/useFirebse.ts

return {
    loading,
    setLoading,
    email,
    setEmail,
    password,
    setPassword,
    handleLogin,
    user,//追加
    setUser,//追加
    learnings,//追加
    setLearnings,//追加
    fetchDb,//追加
    calculateTotalTime//追加
}

これまで解説してきた、新しいステート及び関数をreturnオブジェクトに追加しています。
useFirebaseの変更は以上です。

4.3 Home.tsxの変更

DBデータを取得するよう、カスタムフックを変更・追加しましたので、DBデータを表示するHomeコンポーネントの更新をしていきます。
以下は、Home.tsx変更後のコード全文です。

// /src/components/Home.tsx
import { useEffect } from "react";//useEffect追加
import { Box, Button, Card, CardBody, Flex, Heading, Spinner, Stack, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";//Spinner追加
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";
import { useFirebase } from "../hooks/useFirebase";//カスタムフック、useFirebase追加

const Home = () => {
    const { loading, user, email, learnings, fetchDb, calculateTotalTime } = useFirebase()//useFirebase定義追加
    useEffect(() => {//useEffect追加
        if (user) {//ユーザーがセッション中であれば、
            fetchDb(email)//emailをキーに、FirestoreDBをフェッチ、データを取得
            console.log('Firestore', email)//コンソールログ出力
        }
    }, [user]);// userが更新された時に実行
    return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card size={{ base: 'sm', md: 'lg' }}>
                    <Box textAlign='center' mb={2} mt={10}>
                        {/*ようこそ! test@test.com さんを、下記に変更*/}
                        ようこそ!{email} さん
                    </Box>
                    <Heading size='md' textAlign='center'>Learning Records</Heading>
                    <CardBody>
                        <Box textAlign='center'>
                            学習記録
                            {loading && <Box p={10}><Spinner /></Box> //追加、ローティング中であれば<Spinner />を表示
                            }
                            <TableContainer>
                                <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                                    <Thead>
                                        <Tr>
                                            <Th>学習内容</Th>
                                            <Th>時間()</Th>
                                            <Th></Th>
                                            <Th></Th>
                                        </Tr>
                                    </Thead>
                                    <Tbody>
                                        {learnings.map((learning, index) => (
                                        //mapメソッドでlearningsのタイトルと時間を各々表示
                                            <Tr key={index}>
                                                <Td>{learning.title}</Td>
                                                <Td>{learning.time}</Td>
                                                {/*以下の箇所を上記に変更
                                                <Tr>
                                                    <Td>React</Td>
                                                    <Td>10</Td>
                                                */}
                                                <Td>
                                                    <Button variant='ghost'><FiEdit color='black' /></Button>
                                                </Td>
                                                <Td>
                                                    <Button variant='ghost'><MdDelete color='black' /></Button>
                                                </Td>
                                            </Tr>
                                        ))}
                                    </Tbody>
                                </Table>
                            </TableContainer>
                        </Box>

                        <Box p={5}>
                            {/*<div>合計学習時間:10分</div>の箇所を下記に変更*/}
                            <div>合計学習時間:{calculateTotalTime()}</div>
                        </Box>

                        <Box p={25}>
                            <Stack spacing={3}>
                                <Button
                                    colorScheme='green'
                                    variant='outline'
                                    onClick={() => { }}
                                >新規データ登録
                                </Button>
                            </Stack>
                        </Box>
                        <Box px={25} mb={4}>
                            <Stack spacing={3}>
                                <Button
                                    width='100%'
                                    variant='outline'
                                    onClick={() => { }}
                                >ログアウト</Button>
                            </Stack>
                        </Box>
                        <Box px={25} mb={4}>
                            <Stack spacing={3}>
                                <Button
                                    width='100%'
                                    variant='outline'
                                    onClick={() => { }}
                                >パスワード更新</Button>
                            </Stack>
                        </Box>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}
export default Home;

コード中にコメントを色々記載していますが、解説していきます。
冒頭のインポート箇所は、まずuseEffectのインポートを追加しています。これは、DBデータの取得をuseEffectで実装する為です。他、Chakura UIのコンポーネントにSpinner追加、作成・変更したカスタムフック、useFirebaseのインポートを追加しています。

続いて、コンポーネント定義の下、フックの定義の箇所です。
カスタムフック、useFirebaseの定義をしています。利用するのは、loading, user, email, learnings, fetchDb, calculateTotalTime です。

その下にuseEffectの処理を定義しています。
ここでは、ユーザーがログイン中かどうかを確認の上、ログイン中であれば、FirestoreDBから、そのユーザのemailにマッチするデータの取得を行っています。useEffectの依存関数は、[user]と定義していて、userの値に変化があった際に実行する処理としています。これにより、Loginコンポーネントからログインした際、Homeに遷移する過程で、学習記録のDBデータを取得し、Homeコンポーネントに表示する動きが実装できます。

最後にJSXの箇所です。
(JSXの箇所は、コメントは{/*…*/}の記述形式にする必要があり、少々読みづらいかも知れないですが。なお、JSX内のJavaScriptの定義の途中であれば従来の’//’でコメントアウト可能です。)
まず、ユーザーセッション情報から取得したユーザのemailを表示し、ウェルカムメッセージとしています。学習記録のDBからの読込み中は、ローディング状態を表示するよう、Chakra UIのSpinnerを利用しています。

<Table>で表示している、学習記録の箇所は、React、10と固定値にしていたものを、DBから取得したlearningsステートをmapメソッドで回し、learnings配列のそれぞれのデータを表示させています。
また、学習記録データの下に、useFirebaseのcalculateTotalTime()で学習時間の総計を表示させています。

ここまでの実装で以下画面のような動きとなります。1.5章で作成したFirestoreDBのデータ、ReactとTypeScriptが表示されているのが分かります。

ログイン〜Homeコンポーネントへの処理の流れ
ちなみに、Firebaseのセッション情報は、Supabaseと同様、JWTを利用しています。Firebaseのトークン情報は、ブラウザに実装されているIndexedDBに格納されます。

5. DBデータ更新

続いて、あらかじめFirestoreDBに準備しているコレクションに対し、データ(ドキュメント)を編集・更新する機能を実現していきます。順序的には、データ新規登録機能を先に作成すべきかも知れませんが、この学習記録アプリにおいては、新規学習データ登録時、既存タイトルと被るものは、既存タイトルの更新処理としたいので、そこへの活用も含め、先に更新機能を開発していきます。

5.1 useFirebase,DB更新機能

まずは、カスタムフック、useFirebaseにDB更新処理を追加します。
以下、追加後のuseFirebas.tsのコード全文です。

// /src/hooks/useFirebse.ts
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { signInWithEmailAndPassword, User } from "firebase/auth";
import { collection, doc, getDocs, query, updateDoc, where } from "firebase/firestore";//doc, updateDoc追加
import { auth, db } from "../utils/firebase";
import { StudyData } from "../types/studyData";

type UseFirebase = () => {
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
    email: string;
    setEmail: React.Dispatch<React.SetStateAction<string>>;
    password: string;
    setPassword: React.Dispatch<React.SetStateAction<string>>;
    handleLogin: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
    user: User | null;
    setUser: React.Dispatch<React.SetStateAction<User | null>>;
    learnings: StudyData[];
    setLearnings: React.Dispatch<React.SetStateAction<StudyData[]>>;
    fetchDb: (data: string) => Promise<void>
    calculateTotalTime: () => number
    updateDb: (data: StudyData) => Promise<void>//追加
}

export const useFirebase: UseFirebase = () => {
    const [loading, setLoading] = useState(false);
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [user, setUser] = useState<User | null>(null);
    const [learnings, setLearnings] = useState<StudyData[]>([]);
    const navigate = useNavigate()
    const toast = useToast()

    ////Authentication
    //ログイン処理
    const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setLoading(true);
        try {
            const userLogin = await signInWithEmailAndPassword(auth, email, password);
            console.log("User Logined:", userLogin);
            toast({
                title: 'ログインしました',
                position: 'top',
                status: 'success',
                duration: 2000,
                isClosable: true,
            })
            navigate('/')
        }
        catch (error) {
            console.error("Error during sign up:", error);
            toast({
                title: 'ログインに失敗しました',
                description: `${error}`,
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
        }
        finally {
            setLoading(false);
        }
    };

    //ユーザがセッション中か否かの判定処理
    useEffect(() => {
        const unsubscribed = auth.onAuthStateChanged((user) => {
            setUser(user); // userはnullかUserオブジェクト
            if (user) {
                setEmail(user.email as string)
             } else {
            navigate("/login");//userがセッション中でなければ/loginに移動
             }
        });
        return () => {
            unsubscribed();
        };
    }, [user]);

    ////Firestore 
    //Firestoreデータ取得
    const fetchDb = async (data: string) => {
        setLoading(true);
        try {
            const usersCollectionRef = collection(db, 'users_learnings');
            const q = query(usersCollectionRef, where('email', '==', data)); // ログインユーザーのemailでフィルタ
            const querySnapshot = await getDocs(q);
            const fetchedLearnings = querySnapshot.docs.map((doc) => ({
                ...doc.data(),
                id: doc.id,
            } as StudyData));// Firebaseから取得したデータを`StudyData`型に明示的に変換
            setLearnings(fetchedLearnings); // 正しい型でセット
        }
        catch (error) {
            console.error("Error getting documents: ", error);
        }
        finally {
            setLoading(false);
        }
    }

    //追加:Firestoreデータ更新
    const updateDb = async (data: StudyData) => {
        setLoading(true)
        try {
            const userDocumentRef = doc(db, 'users_learnings', data.id);
            await updateDoc(userDocumentRef, {
                title: data.title,
                time: data.time
            });
            toast({
                title: 'データ更新が完了しました',
                position: 'top',
                status: 'success',
                duration: 2000,
                isClosable: true,
            })
        }
        catch (error) {
            console.log(error)
            toast({
                title: 'データ更新に失敗しました',
                description: `${error}`,
                position: 'top',
                status: 'error',
                duration: 4000,
                isClosable: true,
            })
        } finally {
            setLoading(false)
        }
    }

    ////Others
    //学習時間合計
    const calculateTotalTime = () => {
        return learnings.reduce((total, learning) => total + learning.time, 0);
    };

    return {
        loading,
        setLoading,
        email,
        setEmail,
        password,
        setPassword,
        handleLogin,
        user,
        setUser,
        learnings,
        setLearnings,
        fetchDb,
        calculateTotalTime,
        updateDb//追加
    }
}

コメントで追加と記載している箇所が追加箇所になります。

// /src/hooks/useFirebse.ts
import { collection, doc, getDocs, query, updateDoc, where } from "firebase/firestore";//doc, updateDoc追加
.
.
    updateDb: (data: StudyData) => Promise<void>//追加
.
.
    //追加:Firestoreデータ更新
    const updateDb = async (data: StudyData) => {
.
.
    return {
    .
    .
        updateDb//追加
    }

冒頭インポートの箇所は、FirebaseSDKのfirebase/firestoreから、doc, updateDocメソッドを追加しています。
型定義の箇所は、新たに追加している、DB更新処理の関数、updateDbの型定義を追加しています。

間に追加した関数updateDbの内容があり、最後のreturn文の箇所にupdateDbをオブジェクトとして追加しています。

それでは、追加した関数updateDbについて具体的に説明します。

// /src/hooks/useFirebse.ts

//追加:Firestoreデータ更新
const updateDb = async (data: StudyData) => {//async/awaitで処理実施、dataは学習記録のStudyDataの型
    setLoading(true)//ローディング中にセット
    try {
        const userDocumentRef = doc(db, 'users_learnings', data.id);
        //FirebaseSDKのdocにより、'users_learnings'のdata.idにマッチするドキュメントを取得
        await updateDoc(userDocumentRef, {//FirebaseSDKのupdateDoc()メソッドにより、titleとtimeを更新
            title: data.title,
            time: data.time
        });
        toast({//処理が正常終了すれば、Chakra UIのToast機能で、正常終了メッセージ表示
            title: 'データ更新が完了しました',
            position: 'top',
            status: 'success',
            duration: 2000,
            isClosable: true,
        })
    }
    catch (error) {
        console.log(error)
        toast({//エラー発生時は、Chakra UIのToast機能で、エラーメッセージ表示
            title: 'データ更新に失敗しました',
            description: `${error}`,
            position: 'top',
            status: 'error',
            duration: 4000,
            isClosable: true,
        })
    } finally {
        setLoading(false)//最後にローディング状態を解除
    }
}

コード中にコメントを記載してますが、fetchDbと同様、async/awaitで処理実施しています。dataの型は、StudyDataです。
ローディングをセットして、try文の箇所は、FirebaseSDKのメソッドで更新処理を行っています。

正常終了すれば、ログイン機能の時に利用したように、Chakra UIのToast機能で成功メッセージ表示、エラー発生時は同様にエラーメッセージを表示させています。
最後にローディングを解除して終了です。

5.2 Home.tsxの変更

続いて、useFirebaseで追加したDBデータ更新機能の実装と、編集用モーダルの提供の為、Home.tsxを変更していきます。

元々は、他の学習記録アプリでもそうして来ましたが、編集用コンポーネントとしてEdit.tsxを準備し、それをHomeコンポーネントに配置し、呼び出す形にしようと計画してました。が、どうもカスタムフックや、useContextなど、Propsを集中管理するコンポーネントから利用すると、Edit.tsxでのstate変更がHome.tsxに反映されない等の事象が発生したので、Homeコンポーネント内にモーダルをセットする形としました。なお、AppやHomeにProps定義しそれをEditに渡す場合は問題なく動作します(これまでの記事はそのパターン)。カスタムフック等を利用した場合にうまく動かないという感じです。この辺はstateのセット関数の非同期処理のタイミング等が絡んでるとは思いますが。

それでは、以下、変更後のHome.tsxを掲載します。コード全文です。

// /src/components/Home.tsx
import { useEffect, useRef, useState } from "react";
import {
  Box, Button, Card, CardBody, Flex, FormControl, FormLabel, Heading, Input, Modal, ModalBody,
  ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Stack,
  Table, TableContainer, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast
} from "@chakra-ui/react";//追加:ChakraUIのModal関係インポート
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";
import { useFirebase } from "../hooks/useFirebase";
import { StudyData } from "../types/studyData";//追加:SudyData型定義のインポート

const Home = () => {
  const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb } = useFirebase()//追加:updateDb
  const modalEdit = useDisclosure()//追加:編集用モーダルのクローズ、オープン制御のフック、useDisclosureの定義
  const initialRef = useRef(null)//追加:モーダルオープン時のフォーカス箇所を定義
  const [editLearning, setEditLearning] = useState<StudyData>({//追加:学習記録の登録・更新・削除用のstate
    id: '',
    title: '',
    time: 0
  })
  const toast = useToast()//追加:ChakraUIのToast機能

  useEffect(() => {
    if (user) {
      fetchDb(email)
      console.log('Firestore', email)
    }
  }, [user]);

  const handleUpdate = async () => {//追加:クリック時、DBデータ更新し、その後、更新反映されたDBデータを取得、ローディングが解除されたら、モーダルクローズ
    await updateDb(editLearning);
    fetchDb(email)
    if (!loading) {
      setTimeout(() => {
        modalEdit.onClose();
      }, 500);
    }
  }
  return (
    <>
      <Flex alignItems='center' justify='center' p={5}>
        <Card size={{ base: 'sm', md: 'lg' }}>
          <Box textAlign='center' mb={2} mt={10}>
            ようこそ!{email} さん
          </Box>
          <Heading size='md' textAlign='center'>Learning Records</Heading>
          <CardBody>
            <Box textAlign='center'>
              学習記録
              {loading && <Box p={10}><Spinner /></Box>}
              <TableContainer>
                <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          {/* <Button variant='ghost'><FiEdit color='black' /></Button> 下記編集用モーダル箇所に置換え */}

                          {/* 追加:編集用モーダルここから */}
                          <Button variant='ghost' onClick={() => {
                            setEditLearning(learning)
                            modalEdit.onOpen()
                          }}><FiEdit color='black' /></Button>

                          <Modal
                            initialFocusRef={initialRef}
                            isOpen={modalEdit.isOpen}
                            onClose={modalEdit.onClose}
                          >
                            <ModalOverlay />
                            <ModalContent>
                              <ModalHeader>記録編集</ModalHeader>
                              <ModalCloseButton />
                              <ModalBody pb={6}>
                                <FormControl>
                                  <FormLabel>学習内容</FormLabel>
                                  <Input
                                    ref={initialRef}
                                    placeholder='学習内容'
                                    name='title'
                                    value={editLearning.title}
                                    onChange={(e) => {
                                      setEditLearning({ ...editLearning, title: e.target.value })
                                    }}
                                  />
                                </FormControl>

                                <FormControl mt={4}>
                                  <FormLabel>学習時間</FormLabel>
                                  <Input
                                    type='number'
                                    placeholder='学習時間'
                                    name='time'
                                    value={editLearning.time}
                                    onChange={(e) => {
                                      setEditLearning({ ...editLearning, time: Number(e.target.value) })
                                    }}
                                  />
                                </FormControl>
                                <div>入力されている学習内容:{editLearning.title}</div>
                                <div>入力されている学習時間:{editLearning.time}</div>
                              </ModalBody>
                              <ModalFooter>
                                <Button
                                  isLoading={loading}
                                  loadingText='Loading'
                                  spinnerPlacement='start'
                                  colorScheme='green'
                                  mr={3}
                                  onClick={() => {
                                    if (editLearning.title !== "" && editLearning.time > 0) {
                                      handleUpdate()
                                    }
                                    else {
                                      toast({
                                        title: '学習内容と時間を入力してください',
                                        position: 'top',
                                        status: 'error',
                                        duration: 2000,
                                        isClosable: true,
                                      })
                                    }

                                  }}
                                >
                                  データを更新
                                </Button>
                                <Button onClick={() => {
                                  modalEdit.onClose()
                                }}>Cancel</Button>
                              </ModalFooter>
                            </ModalContent>
                          </Modal>
                          {/* 編集用モーダルここまで */}

                        </Td>
                        <Td>
                          <Button variant='ghost'><MdDelete color='black' /></Button>
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>

            <Box p={5}>
              <div>合計学習時間:{calculateTotalTime()}</div>
            </Box>

            <Box p={25}>
              <Stack spacing={3}>
                <Button
                  colorScheme='green'
                  variant='outline'
                  onClick={() => { }}
                >新規データ登録
                </Button>
              </Stack>
            </Box>
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button
                  width='100%'
                  variant='outline'
                  onClick={() => { }}
                >ログアウト</Button>
              </Stack>
            </Box>
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button
                  width='100%'
                  variant='outline'
                  onClick={() => { }}
                >パスワード更新</Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  )
}
export default Home;

解説していきます。
まず、冒頭のインポート、及びフックの定義です。

// /src/components/Home.tsx
import { useEffect, useRef, useState } from "react";
import {
    Box, Button, Card, CardBody, Flex, FormControl, FormLabel, Heading, Input, Modal, ModalBody,
    ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Stack,
    Table, TableContainer, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast
} from "@chakra-ui/react";//追加:ChakraUIのModal関係インポート
import { FiEdit } from "react-icons/fi";
import { MdDelete } from "react-icons/md";
import { useFirebase } from "../hooks/useFirebase";
import { StudyData } from "../types/studyData";//追加:SudyData型定義のインポート

const Home = () => {
    const { loading, user, email, learnings, fetchDb, calculateTotalTime, updateDb } = useFirebase()//追加:updateDb
    const modalEdit = useDisclosure()//追加:編集用モーダルのクローズ、オープン制御のフック、useDisclosureの定義
    const initialRef = useRef(null)//追加:モーダルオープン時のフォーカス箇所を定義
    const [editLearning, setEditLearning] = useState<StudyData>({//追加:学習記録の登録・更新・削除用のstate
        id: '',
        title: '',
        time: 0
    })
    const toast = useToast()//追加:ChakraUIのToast機能

インポートの箇所は、Chakra UIのインポートが大量に追加となっています。Modal関連のもの多数を追加しています。また、Toastも利用するので、追加しています。
それから、学習記録の型定義、StudyDataのインポート追加です。

フック定義の箇所は、useFirebaseに新たに追加した、updateDbを追加してます。
また、Chakra UIのモーダル機能の実装に当たり、useDisclosure、useRefを追加しています。
useDisclosureは、モーダルのオープン、クローズの制御を行うものです。これはモーダル毎に必要になります(そうしないと一つのコンポーネントに複数のモーダルがある場合、どのモーダルの制御を行うのかが不明確になる為です)。

今回は編集用モーダルの為、modalEditと言う名前にしています。
useRefは、モーダルをオープンした際に、フォーカスを当てる箇所を定義するものです。

その下ですが、ローカルステートとして、editLearningとそのset関数を定義しています。これは編集画面、また、この後、実装していく、新規データ登録画面や、削除画面でも利用するもので、DBデータ操作対象となる、学習記録データを格納するstateです。よって、学習記録データの型、StudyDataを適用しています。

最後は、Chakra UIのToast機能を定義しています。

続いて、入力・編集された値の処理の関数について説明します。

// /src/components/Home.tsx

const handleUpdate = async () => {//追加:クリック時、DBデータ更新し、その後、更新反映されたDBデータを取得、ローディングが解除されたら、モーダルクローズ
    await updateDb(editLearning);//useFirebaseのupdateDbを実行し、DBデータを更新
    fetchDb(email)//DB更新処理終了後、更新反映されたDBデータを改めて取得
    if (!loading) {//ローディング解除されたら、0.5秒後、モーダルをクローズ
        setTimeout(() => {
            modalEdit.onClose();
        }, 500);
    }
}

handleUpdateを追加しています。これが、DBデータ更新処理になります。「データを更新」ボタンをクリックすると発動します。処理内容はコメント記載通りですが、モーダルクローズはステートの更新も考慮して少し間をもたせた処理をしています。setTimeout(() => {}, )の箇所です。

この箇所が冒頭に記載したモーダルを別コンポーネントに分離すると、うまく行かなかった処理です。fetchDbで更新後のデータを再取得し、learningsステートに格納したものがHomeコンポーネントにレンダリングされない状況でした。モーダルをHomeコンポーネントに集約することで問題なく動作するようになりました。

続いて、JSXの箇所です。

// /src/components/Home.tsx
<Td>
    {/* <Button variant='ghost'><FiEdit color='black' /></Button> 下記編集用モーダル箇所に置換え */}

    {/* 追加:編集用モーダルここから */}
    <Button variant='ghost' onClick={() => {//モーダルオープンのボタン部分
        setEditLearning(learning)//クリックしたら、mapで展開されているlearningをeditLearningにセット
        modalEdit.onOpen()//モーダルをオープンする。編集用のモーダルのため、modalEdit.onOpen()の形で指定。
    }}><FiEdit color='black' /></Button>

    <Modal
        initialFocusRef={initialRef}//ここからモーダルの内容
        isOpen={modalEdit.isOpen}//モーダルのオープン状態を監視、編集用のモーダルのため、modalEdit.を付与
        onClose={modalEdit.onClose}//モーダルクローズの定義、編集用のモーダルのため、modalEdit.を付与
    >
        <ModalOverlay />
        <ModalContent>
            <ModalHeader>記録編集</ModalHeader>
            <ModalCloseButton />
            <ModalBody pb={6}>
                <FormControl>
                    <FormLabel>学習内容</FormLabel>
                    <Input
                        ref={initialRef}
                        placeholder='学習内容'
                        name='title'
                        value={editLearning.title}//valueはロカールステートを利用
                        onChange={(e) => {//Inputエリアのtitleの入力値をeditLearning.titleに格納
                           setEditLearning({ ...editLearning, title: e.target.value })
                          }}
                    />
                </FormControl>

                <FormControl mt={4}>
                    <FormLabel>学習時間</FormLabel>
                    <Input
                        type='number'
                        placeholder='学習時間'
                        name='time'
                        value={editLearning.time}//valueはロカールステートを利用
                        onChange={(e) => {//Inputエリアのtitleの入力値をeditLearning.timeに数値に変換して格納
                           setEditLearning({ ...editLearning, time: Number(e.target.value) })
                         }}
                    />
                    />
                </FormControl>
                <div>入力されている学習内容
                    {editLearning.title//ロカールステートを利用
                    }</div>
                <div>入力されている学習時間:{editLearning.time//ロカールステートを利用
                }</div>
            </ModalBody>
            <ModalFooter>
                <Button
                    isLoading={loading}
                    loadingText='Loading'
                    spinnerPlacement='start'
                    colorScheme='green'
                    mr={3}
                    onClick={() => {
                        if (editLearning.title !== "" && editLearning.time > 0) {
                        //学習タイトルと時間が共に入力されていれば、
                            handleUpdate()//DB更新処理実行
                        }
                        else {
                            toast({//学習タイトルと時間が共に入力されていなければ、エラーメッセージ表示
                                title: '学習内容と時間を入力してください',
                                position: 'top',
                                status: 'error',
                                duration: 2000,
                                isClosable: true,
                            })
                        }

                    }}
                >
                    データを更新
                </Button>
                <Button onClick={() => {//cancelクリックの場合、そのままモーダルクローズ
                    modalEdit.onClose()//編集用のモーダルのため、modalEdit.を付与
                }}>Cancel</Button>
            </ModalFooter>
        </ModalContent>
    </Modal>
    {/* 編集用モーダルここまで */}

</Td>

追加した編集用モーダルの箇所のみ掲載しています。
冒頭にある、今まで編集アイコンボタンのみだった、<Button variant=’ghost’><FiEdit color=’black’ /></Button>の箇所を、その下に記載している、「追加:編集用モーダルここから」から一番下側の「編集用モーダルここまで」に置換え・追加します。内容の説明については、コメント記載通りです。
Modalコンポーネントの利用方法は、以下Chakra UIのサイトが参考になります。

InputのonChangeの箇所は、編集されたデータをローカルステートに格納する処理を記載しています。onChangeイベントで発動する処理です。なお、timeはnumber型となりますので、time: Number(e.target.value)と言う書き方になってます。これはイベントターゲット(=Input入力値の変更イベントの対象)のvalueをnumber型に変換するという内容です。

以上がDBデータ更新処理の実装内容となります。この時点で以下gif動画のように、学習記録の編集が可能となります。

編集画面の動画

Firebase、FirestoreDBでのデータが変わった事が確認できます。

FirestoreDB画面

ここまでで前編は終了とします。
後編では、FirestoreDBへのデータ新規登録、削除機能、Firebase Authenticationのサインアップ、パスワード変更機能の実装などを掲載予定です。

React チュートリアル:学習記録アプリシリーズ

No responses yet

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

AD




TWITTER


アーカイブ
OTHER ISSUE
PVアクセスランキング にほんブログ村