[Next.js] 学習記録アプリ Firebase認証・DB実装

Reactのチュートリアルとして、これまで、SupabaseやFirebase連携等、色々なパターンで紹介してきた、学習記録アプリですが、今回は、Next.jsを利用して開発してみましたので、紹介いたします。

Next.jsは、Reactをベースに開発された、フロントエンドフレームワークです。ReactはJavaScript言語を用いた、Webサイト上のUIを構築するためのライブラリで、フレームワークとは、開発を効率化するための枠組みです。
Next.jsは「URLルーティング」と呼ばれるリクエストされたURLに対して呼び出すアクションを決定する仕組みや、Webアプリ開発を効率よくするための機能が多く含まれているのが特長。

Reactをベースに開発されたNext.jsは、Reactと度々比較されますが、Next.jsとReactの1番の違いは、サーバー機能の有無です。
Next.jsはサーバー機能を持っていますが、Reactにはサーバー機能がありません。つまり、Next.jsは単体でWebアプリを動作させることができますが、Reactは別途サーバーを用意する必要があります。サーバーを用意するということは、サーバー用のモジュールをインストールし、ディレクトリ構成などを検討する必要があるため、Reactのほうが学習コストや難易度が高くなります。

2つ目の大きな違いは、Next.jsはURLルーティングを自動生成してくれることです。初期化時に生成されたフォルダにファイルを配置すると、それに合わせてURLが生成されます。

はじめに

本記事は、Next.jsを利用し、FirebaseのAuthenticationによる認証と、Firestore DatabaseによるDBを連携させた学習記録アプリのチュートリアル記事です。
試してみてよく分かったのですが、基本的に、FirebaseSDKはクライアントサイドで動作するように設計されており、サーバーコンポーネントが大きな特徴のNext.jsではさほどメリットは無いかもと言うとこです。
ですが、Next.jsの仕組みを知る上では価値はあると思いますし、あえて、Next.jsを利用することで以下のメリットがあります。

  • ルーティング管理の簡素化:
    Next.jsのファイルベースのルーティングは、React Routerなどを個別に設定する必要がなく、簡潔に構築できます。
  • API Routesでサーバーサイド処理を分離:
    FirebaseのSDKをサーバー側(API Routes)で利用することで、クライアントには安全なデータのみを公開できます。これにより、クライアントSDKを補完する形でセキュアなバックエンド処理を実現できます。
  • SEOとSSRの併用:
    一部のページ(例えば、ブログやプロフィールページなど)はFirebaseから取得したデータをサーバーサイドでレンダリングし、SEOに強いページを作成できます。
  • フロントエンドとバックエンドの統一:
    Firebaseと組み合わせても、API Routesを使うことでバックエンドのコードとフロントエンドのコードを同じプロジェクト内に統合できます。これにより、メンテナンス性が向上します。
  • 未来の拡張性:
    Firebaseだけに依存せず、後々他のバックエンド(例: 自作のデータベースAPIや第三者サービス)を追加したい場合に柔軟に対応できます。

なお、Reactで構築したものは以下で紹介しています。

本記事では、Next.jsはこの時点最新のV15.1.0、及びTypeScriptを使用。共にインストールされるReactは正式リリースされた、V19.0.0です(Reactは Chakra UIのコンポーネントで型エラーが解消出来なかった為、記事内で18.3.1に変更します)。
CSS+UIツールとしては、Chakra UI、アイコンとして、React Icons
BaaSとして、認証機能及び、DB、ホスティングサービスをGoogleのFirebaseで実装でしています。

なお、記事の量が多いので、前編・後編と2つに分けてお送りします。
前編となる今回は、Next.js、Firebaseの環境構築から始め、Next.jsのサーバーコンポーネントの開発、クライアントコンポーネントのDBデータ取得機能までを扱っています。
後編では、DBデータの更新・新規登録・削除機能の実装、ユーザーログイン、サインアップ、パスワード更新・リセット処理の実装、そしてGitHubとの連携とVercelへのデプロイ、またご参考としてAWS Amplifyへのデプロイについてお送りします。

今回作成するアプリの構造は下図のとおりです。

Next.jsアプリ構造

1. 環境構築

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

1.1 Next.jsプロジェクトの作成

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

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

node -v
v22.11.0

npm -v
10.9.0

次にNext.jsプロジェクトを作成します。
ターミナル上で、Next.jsプロジェクトを作成したいディレクトリに移動し、
npx create-next-app でプロジェクト作成を実行します。
プロジェクト名は「next-learning-firebase」で
他の選択肢は下記の通りです。

npx create-next-app next-learnings-firebase
Need to install the following packages:
create-next-app@15.1.0
Ok to proceed? (y)

 Would you like to use TypeScript?   Yes
 Would you like to use ESLint?  No
 Would you like to use Tailwind CSS?  No
 Would you like your code inside a `src/` directory?  No
 Would you like to use App Router? (recommended) … Yes
 Would you like to use Turbopack for `next dev`? … No
 Would you like to customize the import alias (`@/*` by default)? … No

これでNext.jsの初期環境構築は完了です。
続けてプロジェクトディレクトリに移動し、開発サーバー起動をnpm run devで行います。

cd next-learning-firebase
npm run dev

開発サーバーが、http://localhost:3000/ で起動されると思います。

http://localhost:3000/にアクセスすると以下画面が表示されます。

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

npm i react@18 react-dom@18
npm i --save-dev @types/react@18 @types/react-dom@18
npm i @chakra-ui/react@2.10.4 @emotion/react @emotion/styled framer-motion react-icons react-router-dom

Chakra UIついては、V3が最近リリースされており、何も指定しないと、最新のV3がインストールされます。が、まだドキュメント等整備されておらず、仕様もかなり変わってますので、V2の最新バージョンを利用する形にしています。@chakra-ui/react@2.10.4で、バージョン指定しています。
また、Chakra UI のV2はReact18を前提としてます。Next.jsの最新版では、Reactは最近リリースされたV19がインストールされますので、こちらもV18を適用します。
(V19で進めてたんですが、どうにも解決できない型エラーがあり、V18にしました)

1.2 Firebaseの初期設定

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

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

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

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

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

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

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

続いて、Firebase SDKの追加画面が表示されます。そのまま一番下にある、「コンソールに進む」をクリックします。

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

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

1.3 Firebase SDK設定

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

npm install firebase


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

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

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

NEXT_PUBLIC_FIREBASE_API_KEY="YOUR-apiKey"
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="YOUR-authDomain"
NEXT_PUBLIC_FIREBASE_PROJECT_ID="YOUR-projectId"
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET="YOUR-storageBucket"
NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID="YOUR-messagingSenderId"
NEXT_PUBLIC_FIREBASE_APP_ID="YOUR-appId"
なお、Reactの記事で紹介した際は、Viteを利用してましたので、VITE_FIREBASE_...と言う環境変数の記述でしたが、Next.jsの場合は、環境変数は、NEXT_PUBLIC_FIREBASE_...と言う記載の仕方になります。

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

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

// /app/utils/firebase.ts

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

const firebaseConfig = {//.env.localの内容を読み込むよう設定
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);//認証機能の定義
export const db = getFirestore();//DB機能の定義
export const collectionName = "users_learnings"; // Firestoreコレクション名

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

このfirebaseConfigの箇所もNext.jsを利用する場合の記載の仕方(NEXT_PUBLIC_FIREBASE_...)となっていますので、ご注意ください。

ここまでで、ひとまず、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

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

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

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

1.6 型定義ファイルの作成

FirestoreDBの準備ができたところで、Next.jsプロジェクトに、DBデータの型を定義したファイルを作成しておきます。DBに格納した学習記録の型定義ファイルを独立したコンポーネントで作成します。
utilsフォルダに、新たにstudyData.tsと言うファイルを作成します。

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

// /app/utils/studyData.ts

export type StudyData = {
  id?: string; // FirestoreではIDは自動付与なのでオプションとして定義
  email: string; 
  title: string;
  time: number;
};

2. サーバーコンポーネントの作成

続いて、Next.jsプロジェクトで、コードを具体的に記載していきます。まずは、Next.jsのサーバーコンポーネントの開発を行っていきます。

Next.js 13以降で採用された、サーバーコンポーネントは、データ処理をAPIルートを通じて行う方法と、Server Actionsを利用する方法があります。
それぞれのメリット・デメリット、ユースケースは以下の通りです。

  1. APIルートを使う場合
    • メリット
      • 再利用性が高い
        APIルートはフロントエンド、バックエンドを問わず複数のクライアントから利用可能。同じAPIをモバイルアプリや他のサービスでも利用できる。
      • 独立性
        データ取得や操作のロジックを明確に分離できるため、責務が明確。
        バックエンドの役割が明確でテストが容易。
      • 標準化された設計
        RESTやGraphQLなど、既存のベストプラクティスに基づいた設計が可能。
      • セキュリティ
        サーバー側で認証・認可を集中管理できる。
    • デメリット
      • リクエスト/レスポンスのオーバーヘッド
        クライアントとサーバー間でHTTPリクエストが発生するため、多少の遅延が発生する
      • 状態管理の複雑さ
        クライアントサイドでAPIのレスポンスを受け取り、その結果を状態として管理する必要がある。
      • 型の重複
        APIの型とクライアント側の型を整合させるために手間がかかる場合がある。
    • ユースケース
      • 複数のクライアントが同じロジックを利用する:
        モバイルアプリや他のフロントエンドとバックエンドロジックを共有したい場合。
      • データ処理が複雑な場合:
        データベースとの複雑な連携や、外部APIを統合する処理が多い場合。
      • エンタープライズ環境
        他チームや他サービスとAPI仕様を共有する必要がある場合。
  2. Server Actionsを使う場合
    • メリット
      • クライアントコードの簡素化
        データフェッチや状態管理の複雑さが軽減される。
        useEffectや状態管理ライブラリが不要になるケースが多い。
      • パフォーマンスの向上
        サーバーで処理を実行して結果をクライアントに直接送信するため、APIコールに比べてオーバーヘッドが少ない。
      • 型安全性
        TypeScriptと統合されているため、APIとクライアントコード間の型の整合性を確保しやすい。
      • 開発体験の向上
        同じファイル内でUIとサーバーロジックを記述できるため、コンテキストの切り替えが減少する。
    • デメリット
      • 再利用性の低下
        Server ActionsはNext.jsの内部に閉じているため、他のクライアント(モバイルアプリなど)で利用するのが難しい。
      • 依存関係の制約
        サーバーコンポーネント内では特定のライブラリやフック(例: useEffect)が使えない。クライアントコンポーネントに分離する必要が出てくる場合がある。
      • 新しさゆえの不安定性
        Server Actionsはまだ比較的新しい機能のため、将来の変更やアップデートの影響を受けやすい。
    • ユースケース
      • 単一のクライアント向け:
        Next.jsアプリ内だけで使うサーバー側ロジックがある場合。
      • シンプルなデータ操作:
        単純なデータフェッチや簡易な更新操作を行いたい場合。
      • フルスタック開発の効率化:
        UIとデータロジックを同じ場所で管理し、迅速に開発を進めたい場合。

今回の開発に当たっては、APIルートによる実装を行っていきます。
当初は、アプリ全体の簡素化と一体化も考慮し、Server Actionsで実装しようと試行しましたが、クライアントサイド向けのFirebaseSDKを無理矢理使ってるとこもあり、うまく動きませんでした。また、より、ロジックを分離し、処理責務を適切に割り当てるのもあり、APIルートで実装したと言う所です。

が、そもそも、FirebaseSDKでの実装が無理があるとこはあり、このサーバーコンポーネントは、FirestoreDBのCURD操作のみ実装する形にしています。
Authenticationの処理も目論見ましたが、セッション管理がクライアントサイドのSDKで完結しており、これをサーバコンポーネントとして実装するのは、自前でトークンやセッション管理が必要で、非効率だと言う事で、見送りました。

と言う事で、FirestoreDBへのアクション、データ取得・登録・更新・削除、それぞれの機能をサーバーコンポーネントで実装して行きます。サーバーコンポーネントにてDBデータの操作を行っていきますが、これは以下のメソッドを利用して行います。

  • GET・・・データの取得を行う場合
  • POST・・・データの登録を行う場合
  • PUT・・・データの更新を行う場合
  • DELETE・・・データの削除を行う場合

2.1 データ取得機能

まずは、FirestoreDBのデータ取得機能をサーバーコンポーネントで実装します。
appフォルダ配下に、新たにapiフォルダを作成します。その下にrecordsフォルダを作成し、更にその下にreadフォルダを作成します。その中に、route.rsを言うファイルを作成します。

フォルダ構造が深いので、整理すると、/app/api/records/read/route.tsとなります。なお、APIルートのコンポーネントは、route.tsと言うファイル名になります。これは後ほど、触れるpagesファイルと同じですが、これにより、Next.jsでは、フォルダ構造に従ったURLがエンドポイントとして扱われます。

例えば、/app/api/records/read/route.tsの場合は、/api/records/read/でURL指定すると、そのフォルダのroute.tsが実行されます。

route.tsと言うファイル名は、Next.js 13以降からの実装です。ぞれ以前はpagesフォルダにapiフォルダを作成し、自由なファイル名で構成する形でした。Next.js 13以降もappフォルダとは分離してpagesフォルダを構成、その配下に配置する場合は、自由なファイル名でOKなようです。
が、新規にプロジェクトを作成する場合は、appフォルダとroute.tsの構成が推奨されています。

では、route.tsの内容について進めます。以下コードを記載します。

//  /app/api/records/read/route.ts
import { NextResponse } from "next/server";
import { collection, getDocs, query, where } from "firebase/firestore";
import { collectionName, db } from "@/app/utils/firebase";
import { StudyData } from "@/app/utils/studyData";

// **データ取得**
export async function GET(request: Request) {
  //exportで関数エクスポート、GETメソッドでデータ取得
  try {
    const email = new URL(request.url).searchParams.get("email"); //URLにパラメータとして付与されたemailを抽出、emailをキーにデータを取得
    if (!email) {
      //emailが存在しなければ、400エラーを返す
      return NextResponse.json(
        { success: false, error: "Email is required" },
        { status: 400 }
      );
    }

    const studiesRef = collection(db, collectionName); //FirebaseSDKを利用してFirestoreDBデータを取得
    const q = query(studiesRef, where("email", "==", email)); //emailがマッチするデータを取得
    const snapshot = await getDocs(q);

    const data: StudyData[] = snapshot.docs.map(
      //取得したデータをmapメソッドで回し、StudyData型の配列として、dataに格納
      (doc) => ({ id: doc.id, ...doc.data() } as StudyData)
    );

    console.log("GET:", data); // デバッグ用
    //Firebaseからの応答は、successの値を持たないため、successとstatusをreturnするように追加
    return NextResponse.json({ success: true, data }, { status: 200 });
  } catch (error: unknown) {
    console.error("Error fetching studies:", error);
    return NextResponse.json(
      //エラーの場合はsuccessをfalseとして、エラーメッセージ、status500をリターン
      {
        success: false,
        error: (error as Error).message || "Unknown error occurred",
      },
      { status: 500 }
    );
  }
}

解説していきます。冒頭のインポート箇所は、next/serverのNextResponseインポート、FirebaseSDKのFirestoreDB関連の機能のインポート、FirebaseSDK連携用のfirebase.ts、StudyDataの型定義ファイルをインポートしてます。

続いてのDBデータ取得の処理については、コメント記載通りです。
サーバーコンポーネントはクライアントコンポーネントから呼び出して利用されます。
例えば、このデータ取得処理であれば、クライアントコンポーネントから、/api/records/read/に対してリクエストを投げます。この際、データ取得のメソッドはGETとなりますので、パラメータはURLに付与する形態となります。

データ取得に当たっては、ログインしたユーザの情報のみの取得を目的に、ユーザーのemail情報を元にemailがマッチしたデータのみを取得させます。
この為、const email = new URL(request.url).searchParams.get(“email”) で、URLパラメータよりemailを抽出し、これを元にDBデータ検索・取得を行っています(よって、クライアントコンポーネントの実装は、サーバーコンポーネント呼び出しのURLにemail情報をクエリーとして付与する必要があります)。

その後、FirebaseSDKのメソッド(collection, getDocs, query, where)を利用しDBからデータ取得を行っています。

最後にFirebaseからの応答に対し、successのフラグとstatusコードを付加して、クライアントコンポーネントにJSON形式(NextResponse.json)でreturnしてます。

エラー時は、errがunkown型となる為、error.messageに対し、適切な型(Error型)へのキャストもしくは、unkownとしての処理を行っています(こうしないとTypeScriptエラーが出ます)。
error: (error as Error).message || “Unknown error occurred” の箇所です。

2.2 Thunder Clientの利用

VS Codeを利用している場合は、拡張機能である、「Thunder Client」を利用する事で、サーバーコンポーネントの動きを確認する事が可能ですので、ご紹介します。

下記URLにアクセスします。
https://marketplace.visualstudio.com/items?itemName=rangav.vscode-thunder-client
緑の「install」ボタンをクリックすると、インストールが始まります。インストールが完了すると、VS Codeの左サイドに稲妻のアイコンが追加されます。

こちらのアイコンをクリックします。下図のように表示されるので「New Request」をクリックします。

New Requestのタブが新たに開きますので、そちらで確認していきます。
先の2.1章で作成した、データ取得機能を試してみます。

上部のURL入力の箇所に、メソッドは「GET」を選択肢、アドレスは、先程作成したroute.tsのURL、http://localhost:3000/api/records/read/ を入力します。
GETメソッドの場合は、リクエストはQueryとして送信しますので、下のQueryタブに以下の通り入力します。

prameterの箇所:email
valueの箇所:Firebase上で作成したユーザーのメールアドレス

下図の通りです。入力すると、上部のURLの箇所がQueryを反映したものになります。

これで、右上の「send」をクリックします。
そうすると右側のパネルに下記のように実行結果がJSON形式で表示されます。

{
  "success": true,
  "data": [
    {
      "id": "Pq1vUz8ZVGdl56zj0A0j",
      "title": "TypeScript",
      "time": 10,
      "email": "test@test.com"
    },
    {
      "id": "gCLTWHlCluNZWqUizy9P",
      "email": "test@test.com",
      "time": 15,
      "title": "React"
    }
  ]
}

1.5 Firestore Databaseの設定 で作成したデータが取得出来ていることが分かります。

2.3 データ登録機能

続いて、データ新規登録用のサーバーコンポーネントを作成します。api/recordsフォルダに新たにcreateフォルダを作成し、route.tsファイルを作成します。

データの新規登録となりますので、Firebaseに対するメソッドは「POST」を使います。
以下、route.tsのコード内容です。

//  /app/api/records/create/route.ts
import { NextResponse } from "next/server";
import { addDoc, collection } from "firebase/firestore";
import { collectionName, db } from "@/app/utils/firebase";
import { StudyData } from "@/app/utils/studyData";

// **データ追加**
export async function POST(request: Request) {
  //POSTメソッドでデータ新規登録処理
  const body: StudyData = await request.json(); //登録データをStudyData型のオブジェクトとしてJSON形式でrequestに渡す

  if (!body.email || !body.title || body.time === undefined) {
    //リクエスト内容(body)のいずれかが空の場合は、エラーコード400でリターン
    return NextResponse.json({ error: "Invalid data" }, { status: 400 });
  }

  try {
    const studiesRef = collection(db, collectionName); //FirebaseSDKを利用して
    const docRef = await addDoc(studiesRef, body); // FirestoreDBにリクエスト内容を新規登録
    return NextResponse.json({ success: true }); //処理が終了すれば、successフラグをtrueでリターン
  } catch (error: unknown) {
    console.error("Error fetching studies:", error);
    return NextResponse.json(
      //エラーの場合はsuccessをfalseとして、エラーメッセージ、status500をリターン
      {
        success: false,
        error: (error as Error).message || "Unknown error occurred", // error.messgaeはError型もしくは、unkownとして処理
      },
      { status: 500 }
    );
  }
}

解説します。

冒頭のインポート箇所は、next/serverのNextResponseインポート、FirebaseSDKのFirestoreDB関連の機能のインポート、FirebaseSDK連携用のfirebase.ts、StudyDataの型定義ファイルをインポートしてます。

続いてのDBデータ登録の処理については、コメント記載通りです。
データ登録のメソッドはPOSTとなりますので、フォームに入力されたデータをbodyとしてPOSTで送信されます。bodyは学習記録データ、StudyData型として定義しています。
リクエストされたデータ(body)に対し、内容の充足をチェックの上、空などがある場合は、エラーリターン(400エラー)、問題が無ければ、後続のFirebaseSDKのメソッド(addDoc)を利用しDBへのデータ登録処理を行っています。

最後にFirebaseからの応答に対し、successのフラグとstatusコードを付加して、クライアントコンポーネントにJSON形式(NextResponse.json)でreturnしてます。エラー時の処理は、データ取得時と同様です。

Thunder Clientでサーバーコンポーネントの動きを確認してみます。「New Request」をクリックして、表示される画面に以下のように記載します。

URLの箇所は、メソッドは「POST」を選択、URLは、作成したroute.tsのURL、http://localhost:3000/api/records/create/ を入力します。

リクエストの種別は「Body」を選択肢、JSON形式でリクエスト内容を記載します。上図の例では、以下内容です。

{
  "email":"test@test.com",
  "title":"JavaScript",
  "time":10
}

これで、右側の「Send」をクリックします。
そうすると右側のパネルに下記のように実行結果がJSON形式で表示されます。”success”:trueであれば成功です。

{
  "success": true
}

FirestoreDBを確認すると、リクエストされた情報が追加されていることが分かります。

2.4 データ更新機能

次に、データ更新用のサーバーコンポーネントです。api/recordsフォルダに新たにupdateフォルダを作成し、route.tsファイルを作成します。

データの更新となりますので、Firebaseに対するメソッドは「PUT」を使います。
以下、route.tsのコード内容です。

//  /app/api/records/update/route.ts
import { NextResponse } from "next/server";
import { doc, updateDoc } from "firebase/firestore";
import { collectionName, db } from "@/app/utils/firebase";
import { StudyData } from "@/app/utils/studyData";

// **データ更新**
export async function PUT(request: Request) {//PUTメソッドでデータ更新処理
  const body: StudyData = await request.json();//更新データをStudyData型のオブジェクトとしてJSON形式でrequestに渡す

  if (!body.id || !body.email || !body.title || body.time === undefined) {//リクエスト内容(body)のいずれかが空の場合は、エラーコード400でリターン
    return NextResponse.json({ error: "Invalid data" }, { status: 400 });
  }

  try {
    const docRef = doc(db, collectionName, body.id); //FirebaseSDKを利用して
    await updateDoc(docRef, { title: body.title, time: body.time }); // FirestoreDBのリクエスト内容、更新処理
    return NextResponse.json({ success: true }); //処理が終了すれば、successフラグをtrueでリターン
  } catch (error: unknown) {
    console.error("Error fetching studies:", error);
    return NextResponse.json(//エラーの場合はsuccessをfalseとして、エラーメッセージ、status500をリターン
      {
        success: false,
        error: (error as Error).message || "Unknown error occurred",// error.messgaeはError型もしくは、unkownとして処理
      },
      { status: 500 }
    );
  }
}

解説です。冒頭のインポート箇所は、next/serverのNextResponseインポート、FirebaseSDKのFirestoreDB関連の機能のインポート、FirebaseSDK連携用のfirebase.ts、StudyDataの型定義ファイルをインポートしてます。

続いてのDBデータ更新の処理については、コメント記載通りです。
データ更新のメソッドはPUTとなりますので、フォームに入力されたデータをbodyとしてPUTで送信されます。bodyは学習記録データ、StudyData型として定義しています。

リクエストされたデータ(body)に対し、内容の充足をチェックの上、空などがある場合は、エラーリターン(400エラー)、問題が無ければ、後続のFirebaseSDKのメソッド(updateDoc)を利用しDBへのデータ更新処理を行っています。
この際、データのid(body.id)を指定して、該当するidのデータ更新を行っています。

最後にFirebaseからの応答に対し、successのフラグとstatusコードを付加して、クライアントコンポーネントにJSON形式(NextResponse.json)でreturnしてます。
エラー時の処理は、データ取得・登録時と同様です。

Thunder Clientでサーバーコンポーネントの動きを確認します。「New Request」をクリックして、表示される画面に以下のように記載します。

URLの箇所は、メソッドは「PUT」を選択、URLは、作成したroute.tsのURL、http://localhost:3000/api/records/update/ を入力します。

リクエストの種別は「Body」を選択肢、JSON形式でリクエスト内容を記載します。上図の例では、以下内容です。id情報を元に更新を行いますので、idを付与しています。

{
  "id":"h18DYOrWx0bsxcLjLfhN",
  "email":"test@test.com",
  "title":"JavaScript",
  "time":25
}

右側の「Send」をクリックします。右側のパネルに下記のように実行結果がJSON形式で表示されます。”success”:trueであれば成功です。

{
  "success": true
}

FirestoreDB上でもリクエストされた情報が更新されていることが分かります。

2.5 データ削除機能

最後に、データ削除用のサーバーコンポーネントです。
api/recordsフォルダに新たにdeleteフォルダを作成し、route.tsファイルを作成します。

データの削除となりますので、Firebaseに対するメソッドは「DELETE」を使います。
以下、route.tsのコード内容です。

//  /app/api/records/delete/route.ts
import { NextResponse } from "next/server";
import { deleteDoc, doc } from "firebase/firestore";
import { collectionName, db } from "@/app/utils/firebase";
import { StudyData } from "@/app/utils/studyData";

// **データ削除**
export async function DELETE(request: Request) {
  //DELETEメソッドでデータ削除処理
  const body: StudyData = await request.json(); //削除データをStudyData型のオブジェクトとしてJSON形式でrequestに渡す

  if (!body.id) {
    //削除対象データのid(body.id)が空の場合は、エラーコード400でリターン
    return NextResponse.json({ error: "ID is required" }, { status: 400 });
  }

  try {
    const docRef = doc(db, collectionName, body.id); //FirebaseSDKを利用して
    await deleteDoc(docRef); // FirestoreDBの対象idのデータを削除
    return NextResponse.json({ success: true }); //処理が終了すれば、successフラグをtrueでリターン
  } catch (error: unknown) {
    console.error("Error fetching studies:", error);
    return NextResponse.json(
      //エラーの場合はsuccessをfalseとして、エラーメッセージ、status500をリターン
      {
        success: false,
        error: (error as Error).message || "Unknown error occurred", // error.messgaeはError型もしくは、unkownとして処理
      },
      { status: 500 }
    );
  }
}

解説です。
冒頭のインポート箇所は、next/serverのNextResponseインポート、FirebaseSDKのFirestoreDB関連の機能のインポート、FirebaseSDK連携用のfirebase.ts、StudyDataの型定義ファイルをインポートしてます。

続いてのDBデータ削除の処理については、コメント記載通りです。

データ削除のメソッドはDELETEとなりますので、フォームに入力されたデータをbodyとしてPUTで送信されます。bodyは学習記録データ、StudyData型として定義しています。

データ削除はidを元に行いますので、リクエストされたデータ(body.id)に対し、内容チェックの上、空の場合は、エラーリターン(400エラー)、問題が無ければ、後続のFirebaseSDKのメソッド(deleteDoc)を利用しidを元に該当するidのデータ削除処理を行っています。

最後にFirebaseからの応答に対し、successのフラグとstatusコードを付加して、クライアントコンポーネントにJSON形式(NextResponse.json)でreturnしてます。エラー時の処理は、取得・登録・更新時と同様です。

Thunder Clientでサーバーコンポーネントの動きを確認します。「New Request」をクリックして、表示される画面に以下のように記載します。

URLの箇所は、メソッドは「DELETE」を選択、URLは、作成したroute.tsのURL、http://localhost:3000/api/records/delete/ を入力します。

リクエストの種別は「Body」を選択肢、JSON形式でリクエスト内容を記載します。上図の例では、以下内容です。id情報を元に削除を行いますので、idを付与しています。また渡す情報はidのみですので、idのみ記載しています。

{
  "id":"h18DYOrWx0bsxcLjLfhN"
}

右側の「Send」をクリックします。右側のパネルに下記のように実行結果がJSON形式で表示されます。”success”:trueであれば成功です。

{
  "success": true
}

FirestoreDB上でもリクエストされた情報が削除されていることが分かります。

3. DBデータ操作コンポーネント

サーバーコンポーネントの作成が完了しましたので、続いてクライアントコンポーネントを開発していきます。
まずは、DBデータの取得・登録・更新・削除のクライアントコンポーネントを実装していきます。クライアント側からサーバーコンポーネントを呼び出し、DBデータ操作を行います。

DB操作関係のコンポーネントは、componentsフォルダに作成していきます。
appフォルダ直下にcomponentフォルダを作成します。そして、componentフォルダ内に以下のファイルを作成します。

  • Records.tsx ・・・ 学習記録をDBより取得し表示
  • Edit.tsx ・・・ 学習記録を編集し、DBに反映
  • NewEntry.tsx ・・・ 学習記録をDBに新規登録
  • Delete.tsx  ・・・ 学習記録をDBから削除

3.1 ベースデザインの作成

それぞれのファイルに以下コードを記載します。まずは、枠だけを作成する形です。
まず、Records.tsxです。

// /app/components/Records.tsx
"use client"

import Edit from "./Edit"//Editコンポーネントインポート
import Delete from "./Delete"//Deleteコンポーネントインポート
import NewEntry from "./NewEntry"//NewEntryコンポーネントインポート

const Records = () => {
return(
<>
<div>学習記録</div >
<Edit />
<Delete />
<NewEntry />
</>
)
}
export default Records

冒頭に、”use client”と記述しています。これはNext.jsにおいて、クライアントコンポーネントであることを宣言するものです。
ReactのuseState,useEffect等のHooksは、クライアントコンポーネントでしか利用できない為、利用する場合はこの宣言が必須となります。

またRecordsコンポーネントにEdit、Delete、NewEntryを表示する形にしています(これはこの後、Modalスタイルで実装します)。これに伴い、Edit、Delete、NewEntryのインポートも行っています。

続いて、Edit.tsxです。

// /app/components/Edit.tsx
"use client";

const Edit = () => {
  return <div>学習記録編集</div>;
};
export default Edit;

“use client”記載の後、Reactの雛形の記載をしています。

以下、同様にNewEntry.tsx、Delete.tsxです。

// /app/components/NewEntry.tsx
"use client";

const NewEntry = () => {
  return <div>学習記録登録</div>;
};
export default NewEntry;

// /app/components/Delete.tsx
"use client";

const Delete = () => {
  return <div>学習記録削除</div>;
};
export default Delete;


次に、appフォルダ直下のpage.tsxとlayout.tsxを変更し、作成した各コンポーネントを表示出来るようにします。まず、app/page.tsxです。
page.tsxはデフォルトで色々コードが記述されてますが、一旦、全て削除して、以下コードを記載します。

// /app/page.tsx
import Records from "./components/Records";//Recordsをインポート

export default function Home() {
  return (
    <div>
      <Records />
    </div>
  );
}

<Records />でRecordsコンポ―ネントを表示させています。

続いて、layout.tsxです。こちらもデフォルトで色々コードが記述されていますが、以下内容に変更します。

// /app/layout.tsx
import type { Metadata } from "next";
//import { Geist, Geist_Mono } from "next/font/google";
//import "./globals.css";


export const metadata: Metadata = {
  title: "学習記録アプリ with Firebase",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        {children}
      </body>
    </html>
  );
}

デフォルトでインポートされていた、フォント関係とCSSはコメントアウト(削除)してます。なお、Metadataの箇所は残し、titleを適切な内容に変更しています。
余談ですが、このMetadataはNext.jsの特色の一つです。SEO対策の為のMetaデータの実装が可能です(なお、最近リリースされた、React19でもメタデータがサポートされました)。

この時点で、http://localhost:3000/ にアクセスすると、以下画面が表示されます。
Records、Edit、Delete、NewEntryの内容がそれぞれ表示されている形です。

3.2 Recordsのレイアウト作成

続いて、各コンポーネントにChakraUIによる画面レイアウト・デザインを適用していきます。また、stateの定義を行い、stateの値を表示出来るように枠組みを作成していきます。

まず、最初にChakra UIを本アプリで利用するため、アプリのレイアウト全体を制御する、app/layout.tsxで<ChakraProvider>をラップします。コードは以下の通りです。

// /app/layout.tsx
import type { Metadata } from "next";
import { ChakraProvider } from "@chakra-ui/react";//ChakraUI、ChakraProviderインポート
//import { Geist, Geist_Mono } from "next/font/google";
//import "./globals.css";

export const metadata: Metadata = {
  title: "学習記録アプリ with Firebase",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        {/*ChakuraUI追加 */}
        <ChakraProvider>{children}</ChakraProvider>
      </body>
    </html>
  );
}

JSXの箇所で、その要素となる、{children}を<ChakraProvider>でラップしています。

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

続いて、Records.tsxを変更していきます。ChakraUIの適用、及び、ひとまず必要なステート設定などを実施していきます。
以下、Records.tsxの変更内容、コード全文です。

// /app/components/Records.tsx

"use client";
import { useRef, useState } from "react"; //useRef,useStateインポート
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogCloseButton,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogOverlay,
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Stack,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  useDisclosure,
} from "@chakra-ui/react"; //ChakraUI関連のインポート
import { StudyData } from "../utils/studyData"; //型定義、StudyDataのインポート
import Edit from "./Edit";
import Delete from "./Delete";
import NewEntry from "./NewEntry";

const Records = () => {
  const [email, setEmail] = useState("test@test.com"); //email情報用state、一旦、固定値でセット
  const [learnings, setLearnings] = useState<StudyData[]>([
    //学習データ、一旦、固定値をセット
    {
      title: "React",
      time: 15,
      email: email,
    },
    {
      title: "TypeScript",
      time: 10,
      email: email,
    },
  ]);
  const { isOpen, onOpen, onClose } = useDisclosure(); //ChakraUIのAlertDialog用フック
  const cancelRef = useRef(null); //ChakraUIのAlertDialog用フック

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

  return (
    <>
      <Flex
        alignItems="center"
        justify="center"
        p={5} //flex適用
      >
        <Card
          size={{ base: "sm", md: "lg" }} //ChakraUIのCardコンポーネント適用
        >
          <Box textAlign="center" mb={2} mt={10}>
            ようこそ!{email} さん
          </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>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit learning={learning} />
                        </Td>
                        <Td>
                          <Delete learning={learning} />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            <Box p={5}>
              <div>合計学習時間:{calculateTotalTime()}</div>
            </Box>

            {/*データ新規登録*/}
            <Box p={25}>
              <NewEntry learnings={learnings} />
            </Box>

            {/* ログアウト*/}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={onOpen}>
                  ログアウト
                </Button>
                <AlertDialog
                  motionPreset="slideInBottom"
                  leastDestructiveRef={cancelRef}
                  onClose={onClose}
                  isOpen={isOpen}
                  isCentered //ChakraUIのAlertDialog適用
                >
                  <AlertDialogOverlay />
                  <AlertDialogContent>
                    <AlertDialogHeader>ログアウト</AlertDialogHeader>
                    <AlertDialogCloseButton />
                    <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                    <AlertDialogFooter>
                      <Button ref={cancelRef} onClick={onClose}>
                        Cancel
                      </Button>
                      <Button
                        loadingText="Loading"
                        spinnerPlacement="start"
                        colorScheme="red"
                        ml={3}
                        onClick={() => {}}
                      >
                        ログアウト
                      </Button>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </Stack>
            </Box>

            {/*パスワード更新 */}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={() => {}}>
                  パスワード更新
                </Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};

export default Records;

解説していきます。
冒頭のインポートの箇所は、以下追加しています。

  • Reactのフック、useRef, useStateをインポート
  • ChakraUI関連のモジュールをインポート
  • 学習記録の型定義、StudyDataをインポート

続いてフック定義の箇所です。

  // /app/components/Records.tsx
  
  const [email, setEmail] = useState("test@test.com"); //email情報用state、一旦、固定値でセット
  const [learnings, setLearnings] = useState<StudyData[]>([
    //学習データ、一旦、固定値をセット
    {
      title: "React",
      time: 15,
      email: email,
    },
    {
      title: "TypeScript",
      time: 10,
      email: email,
    },
  ]);
  const { isOpen, onOpen, onClose } = useDisclosure(); //ChakraUIのAlertDialog用フック
  const cancelRef = useRef(null); //ChakraUIのAlertDialog用フック

ステートとしてuseStateで、emailとlearningsを定義しています。共に、どのような表示になるか確認の意図もあり、一旦、固定値をセットしています。ここは後ほど、ロジックに従った内容に変更していきます。

その他、ChakraUIのAlertDialog利用のために、useDisclosure、useRefのフック定義をしています。

次に、calculateTotalTimeの箇所です。
学習記録の合計時間を表示するために、この関数を定義しています。

// /app/components/Records.tsx

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

JavaScriptの関数reduceを利用して、learningsに格納された、timeの値の総和を出しています。

return以降のJSXの箇所は、ChakraUIのコンポーネントを利用してレイアウトとデザインを組み上げています。コメント通りの内容ですが、ChakraUIのCardコンポーネントをベースに中身のコンテンツの表示させています。

学習記録については、Tableによるレイアウトでmapメソッドにてlearningsの内容を一つずつ表示させています。また、編集・削除用に、Editコンポーネント、Deleteコンポーネントを配置しています。

// /app/components/Records.tsx

{/*学習記録表示 */}
 <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>
         {learnings.map(
           (
             learning,
             index //mapによりlearningsの内容表示
           ) => (
             <Tr key={index}>
               <Td>{learning.title}</Td>
               <Td>{learning.time}</Td>
               <Td>
                 <Edit
                   learning={learning}//Editコンポーネント配置、propsを渡す
                 />
               </Td>
               <Td>
                 <Delete
                   learning={learning}//Deleteコンポーネント配置、propsを渡す
                 />
               </Td>
             </Tr>
           )
         )}
       </Tbody>
     </Table>
   </TableContainer>
 </Box>


その他、新規登録用のボタン(これはNewEntryコンポーネントを配置しています。)、ログアウト用のボタン、パスワード更新用のボタンを配置をしています。
ログアウト処理には、ChakraUIの AlertDialog による実装をしています。

なお、現時点では、ボタンをクリックした場合の挙動は、仮で、onClick={() => {}}と空の状態としています。これは、後ほど、機能を実装していきます。

3.3 Editのレイアウト作成

次に、Editコンポーネントを変更していきます。ChakraUIの適用、及び、ひとまず必要なステート設定などを実施していきます。Editコンポーネントは、ChakraUIのModal機能を利用します。Recordsコンポーネントで表示された編集アイコンボタンをクリックすると、EditコンポーネントのModalがオープンする形で実装します。

以下、Edit.tsxの変更内容、コード全文です。

// /app/components/Edit.tsx

"use client";

import React, { useRef, useState } from "react"; //useRef,useStateインポート
import {
  Button,
  FormControl,
  FormLabel,
  Input,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  useDisclosure,
} from "@chakra-ui/react"; //ChakraUI関連のインポート
import { FiEdit } from "react-icons/fi"; //編集用アイコンのインポート
import { StudyData } from "../utils/studyData"; //型定義、StudyDataのインポート

type Props = {
  //受け取るpropsの型定義
  learning: StudyData;
};

const Edit: React.FC<Props> = ({ learning }) => {
  //型はReact.FC<Props>、propsは、learning,
  const [updateLearning, setUpdateLearning] = useState(learning); //学習データのローカルステート
  const { isOpen, onOpen, onClose } = useDisclosure(); //ChakraUIのModal用フック
  const initialRef = useRef(null); //ChakraUIのModal用フック

  return (
    <>
      {/*モーダル開閉ボタン*/}
      <Button variant="ghost" onClick={onOpen}>
        <FiEdit color="black" />
      </Button>

      {/*モーダル本体 */}
      <Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>記録編集</ModalHeader>
          <ModalCloseButton />
          <ModalBody pb={6}>
            <FormControl>
              <FormLabel>学習内容</FormLabel>
              <Input
                ref={initialRef}
                placeholder="学習内容"
                name="title"
                value={updateLearning.title}
                onChange={() => {}} //仮設置
              />
            </FormControl>

            <FormControl mt={4}>
              <FormLabel>学習時間</FormLabel>
              <Input
                type="number"
                placeholder="学習時間"
                name="time"
                value={updateLearning.time}
                onChange={() => {}} //仮設置
              />
            </FormControl>
            <div>入力されている学習内容:{updateLearning.title}</div>
            <div>入力されている学習時間:{updateLearning.time}</div>
          </ModalBody>

          <ModalFooter>
            <Button colorScheme="green" mr={3} onClick={() => {}}>
              データを更新
            </Button>
            <Button onClick={onClose}>Cancel</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  );
};

export default Edit;

解説していきます。
冒頭のインポートの箇所は、以下追加しています。

  • Reactのフック、useRef, useStateをインポート
  • ChakraUI関連のモジュールをインポート
  • 編集用アイコンのインポート
  • 学習記録の型定義、StudyDataをインポート

続いて型定義、コンポーネント定義、フック定義の箇所です。

// /app/components/Edit.tsx

type Props = {
  //受け取るpropsの型定義
  learning: StudyData;
};

const Edit: React.FC<Props> = ({ learning }) => {
  //型はReact.FC<Props>、propsは、learning
  const [updateLearning, setUpdateLearning] = useState(learning); //学習データのローカルステート
  const { isOpen, onOpen, onClose } = useDisclosure(); //ChakraUIのModal用フック
  const initialRef = useRef(null); //ChakraUIのModal用フック

コメント記載通りですが、型定義はRecordsコンポーネントから受け取るpropsの型を定義しています。learning, emailです。

propsは、親コンポーネントから子コンポーネントへ値を渡すための仕組みです。

コンポーネントの型はReact.FC<Props>、渡されるpropsは、先の型定義の通り、learningです。
ステート、updateLearningは、学習データ更新用のローカルステートとして定義しています。propsとして渡されるlearningを初期値としてセットしてます。
その他、ChakraUIのModal利用のために、useDisclosure、useRefのフック定義をしています。

続いて、JSXの箇所です。

// /app/components/Edit.tsx

return (
  <>
    {/*モーダル開閉ボタン*/}
    <Button variant="ghost" onClick={onOpen}>
      <FiEdit color="black" />
    </Button>

    {/*モーダル本体 */}
    <Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>記録編集</ModalHeader>
        <ModalCloseButton />
        <ModalBody pb={6}>
          <FormControl>
            <FormLabel>学習内容</FormLabel>
            <Input
              ref={initialRef}
              placeholder="学習内容"
              name="title"
              value={updateLearning.title}
              onChange={() => {}} //仮設置
            />
          </FormControl>

          <FormControl mt={4}>
            <FormLabel>学習時間</FormLabel>
            <Input
              type="number"
              placeholder="学習時間"
              name="time"
              value={updateLearning.time}
              onChange={() => {}} //仮設置
            />
          </FormControl>
          <div>入力されている学習内容:{updateLearning.title}</div>
          <div>入力されている学習時間:{updateLearning.time}</div>
        </ModalBody>

        <ModalFooter>
          <Button colorScheme="green" mr={3} onClick={() => {}}>
            データを更新
          </Button>
          <Button onClick={onClose}>Cancel</Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  </>
);

return以降のJSX部分は、ChakraUIのコンポーネントを利用してレイアウトとデザインを組み上げています。コメント通りの内容ですが、Editコンポーネントは、Modalで作成しています。最初がモーダル開閉ボタンで構成され、{/*モーダル本体 */}とコメントしている箇所以降がModalの内容となります。

データ編集用に<Input>を配置しています。inputフィールドの入力データを更新した場合に処理される、onChangeの箇所は、現時点は空の仮設置としてます。
同様に、<Button>の箇所は、「データを更新」ボタンをクリック(onClick)すると、Firestoreのデータを更新する処理をさせる形となりますが、現時点はonClick時の処理は空内容の仮設置としてます。実際の処理は、後ほど開発していきます。

3.4 Deleteのレイアウト作成

続いて、Deleteコンポーネントを変更していきます。Editコンポーネントと同様、ChakraUIのModalを利用して構成しています。Recordsコンポーネントで表示された削除アイコンボタンをクリックすると、DeleteコンポーネントのModalがオープンする形で実装します。以下、Delete.tsxのコード全文です。

// /app/components/Delete.tsx

"use client";

import React, { useRef } from "react";//useRefインポート
import {
  Box,
  Button,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  useDisclosure,
} from "@chakra-ui/react";//ChakraUI関連のインポート
import { MdDelete } from "react-icons/md";//削除用アイコンのインポート
import { StudyData } from "../utils/studyData";//型定義、StudyDataのインポート

type Props = {
  //受け取るpropsの型定義
  learning: StudyData;
};

const Delete: React.FC<Props> = ({
  //型はReact.FC<Props>、propsは、learning
  learning,
}) => {
  const { isOpen, onOpen, onClose } = useDisclosure(); //ChakraUIのModal用フック
  const initialRef = useRef(null); //ChakraUIのModal用フック

  return (
    <>
      {/*モーダル開閉ボタン*/}
      <Button variant="ghost" onClick={onOpen}>
        <MdDelete color="black" />
      </Button>

      {/*モーダル本体 */}
      <Modal isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>データ削除</ModalHeader>
          <ModalCloseButton />
          <ModalBody pb={6}>
            <Box>
              以下のデータを削除します
              <br />
              学習内容:{learning.title}、学習時間:{learning.time}
            </Box>
          </ModalBody>
          <ModalFooter>
            <Button onClick={onClose} mr={3}>
              Cancel
            </Button>
            <Button
              ref={initialRef}
              colorScheme="red"
              onClick={() => {}} //仮設置
            >
              削除
            </Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  );
};

export default Delete;

解説します。
冒頭のインポートの箇所は、以下追加しています。

  • Reactのフック、useRefをインポート
  • ChakraUI関連のモジュールをインポート
  • 削除用アイコンのインポート
  • 学習記録の型定義、StudyDataをインポート

続いて型定義、コンポーネント定義、フック定義の箇所です。

// /app/components/Delete.tsx

type Props = {
  //受け取るpropsの型定義
  learning: StudyData;
};

const Delete: React.FC<Props> = ({
  //型はReact.FC<Props>、propsは、learning
  learning,
}) => {
  const { isOpen, onOpen, onClose } = useDisclosure(); //ChakraUIのModal用フック
  const initialRef = useRef(null); //ChakraUIのModal用フック

型定義はRecordsコンポーネントから受け取るprops、 learning, emailの型を定義しています。

コンポーネントの型はReact.FC<Props>、渡されるpropsは、先の型定義の通り、learning, emailです。
他、ChakraUIのModal利用のために、useDisclosure、useRefのフック定義をしています。

続いて、JSXの箇所です。

// /app/components/Delete.tsx

return (
  <>
    {/*モーダル開閉ボタン*/}
    <Button variant="ghost" onClick={onOpen}>
      <MdDelete color="black" />
    </Button>

    {/*モーダル本体 */}
    <Modal isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>データ削除</ModalHeader>
        <ModalCloseButton />
        <ModalBody pb={6}>
          <Box>
            以下のデータを削除します
            <br />
            学習内容:{learning.title}、学習時間:{learning.time}
          </Box>
        </ModalBody>
        <ModalFooter>
          <Button onClick={onClose} mr={3}>
            Cancel
          </Button>
          <Button
            ref={initialRef}
            colorScheme="red"
            onClick={() => {}} //仮設置
          >
            削除
          </Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  </>
);

JSXは、ChakraUIのコンポーネントを利用してレイアウトとデザインを組み上げています。Deleteコンポーネントは、Editと同様、Modalで作成しています。最初がモーダル開閉ボタンで構成され、{/*モーダル本体 */}とコメントしている箇所以降がModalの内容となります。

削除アイコン、<MdDelete color=”black” />が、モーダルの開閉ボタンとして機能しています。クリックすると、モーダルが表示され、データを削除するメッセージと削除内容を表示します。削除ボタンをクリックすると、onClick処理により、Firestoreのデータを削除する動きとなりますが、現時点は、空内容の仮設置としてます。実際の処理は、後ほど実装します。

3.5 NewEntryのレイアウト作成

最後に、新規データ登録用のNewEntryコンポーネントを変更していきます。こちらも、Edit、Deleteと同様、ChakraUIのModalを利用した構成です。Recordsコンポーネントに表示される「新規データ登録」ボタンをクリックすると、データ登録用のモーダルがオープンする形です。
以下、NewEntry.tsxのコード全文です。

// /app/components/NewEntry.tsx

"use client";

import { useRef, useState } from "react";//useRef,useStateインポート
import {
  Button,
  FormControl,
  FormLabel,
  Input,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  Stack,
  useDisclosure,
} from "@chakra-ui/react"; //ChakraUI関連のインポート
import { StudyData } from "../utils/studyData";//型定義、StudyDataのインポート

type Props = {
  //受け取るpropsの型定義
  learnings: StudyData[];
};

const NewEntry: React.FC<Props> = ({
  //型はReact.FC<Props>、propsは、learning
  learnings,
}) => {
  const [entryLearning, SetEntryLearning] = useState<StudyData>({
    //学習データの登録用ステート
    id: "",
    title: "",
    time: 0,
    email: "",
  });
  const { isOpen, onOpen, onClose } = useDisclosure(); //ChakraUIのModal用フック
  const initialRef = useRef(null); //ChakraUIのModal用フック

  return (
    <>
      {/*モーダル開閉ボタン*/}
      <Stack spacing={3}>
        <Button colorScheme="green" variant="outline" onClick={onOpen}>
          新規データ登録
        </Button>
      </Stack>

      {/*モーダル本体 */}
      <Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>新規データ登録</ModalHeader>
          <ModalCloseButton />
          <ModalBody pb={6}>
            <FormControl>
              <FormLabel>学習内容</FormLabel>
              <Input
                ref={initialRef}
                name="title"
                placeholder="学習内容"
                value={entryLearning.title}
                onChange={() => {}} //仮設置
              />
            </FormControl>

            <FormControl mt={4}>
              <FormLabel>学習時間</FormLabel>
              <Input
                type="number"
                name="time"
                placeholder="学習時間"
                value={entryLearning.time}
                onChange={() => {}} //仮設置
              />
            </FormControl>
            <div>入力されている学習内容:{entryLearning.title}</div>
            <div>入力されている学習時間:{entryLearning.time}</div>
          </ModalBody>
          <ModalFooter>
            <Button
              colorScheme="green"
              mr={3}
              onClick={() => {}} //仮設置
            >
              登録
            </Button>
            <Button
              onClick={onClose}
            >
              Cancel
            </Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  );
};
export default NewEntry;

解説していきます。
冒頭のインポートの箇所は、以下追加しています。

  • Reactのフック、useRef, useStateをインポート
  • ChakraUI関連のモジュールをインポート
  • 学習記録の型定義、StudyDataをインポート

続いて型定義、コンポーネント定義、フック定義の箇所です。

// /app/components/NewEntry.tsx

type Props = {
  //受け取るpropsの型定義
  learnings: StudyData[];
};

const NewEntry: React.FC<Props> = ({
  //型はReact.FC<Props>、propsは、learning
  learnings,
}) => {
  const [entryLearning, SetEntryLearning] = useState<StudyData>({
    //学習データの登録用ステート
    id: "",
    title: "",
    time: 0,
    email: "",
  });
  const { isOpen, onOpen, onClose } = useDisclosure(); //ChakraUIのModal用フック
  const initialRef = useRef(null); //ChakraUIのModal用フック

コメント記載通りですが、型定義はRecordsコンポーネントから受け取るpropsの型を定義しています。learning, emailです。

コンポーネントの型はReact.FC<Props>、渡されるpropsは、先の型定義の通り、learning, emailです。
ステート、entryLearningは、登録用学習データのローカルステートとして定義しています。型は、StudyDataで定義されるオブジェクトとなります。その初期値は、オブジェクトのそれぞれの内容を定義しています。
その他、ChakraUIのModal利用のために、useDisclosure、useRefのフック定義をしています。

最後にJSXの箇所です。

return (
  <>
    {/*モーダル開閉ボタン*/}
    <Stack spacing={3}>
      <Button colorScheme="green" variant="outline" onClick={onOpen}>
        新規データ登録
      </Button>
    </Stack>

    {/*モーダル本体 */}
    <Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>新規データ登録</ModalHeader>
        <ModalCloseButton />
        <ModalBody pb={6}>
          <FormControl>
            <FormLabel>学習内容</FormLabel>
            <Input
              ref={initialRef}
              name="title"
              placeholder="学習内容"
              value={entryLearning.title}
              onChange={() => {}} //仮設置
            />
          </FormControl>

          <FormControl mt={4}>
            <FormLabel>学習時間</FormLabel>
            <Input
              type="number"
              name="time"
              placeholder="学習時間"
              value={entryLearning.time}
              onChange={() => {}} //仮設置
            />
          </FormControl>
          <div>入力されている学習内容:{entryLearning.title}</div>
          <div>入力されている学習時間:{entryLearning.time}</div>
        </ModalBody>
        <ModalFooter>
          <Button
            colorScheme="green"
            mr={3}
            onClick={() => {}} //仮設置
          >
            登録
          </Button>
          <Button
            onClick={onClose}
          >
            Cancel
          </Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  </>
);

構造としては、Editコンポーネントとほぼ同じです。ChakraUIのコンポーネントを利用してレイアウトとデザインを組み上げ、Modalで作成しています。最初がモーダル開閉ボタンで構成され、{/*モーダル本体 */}とコメントしている箇所以降がModalの内容です。
Inputの設置についても、Editコンポーネントとほぼ同じ構成で、Input入力エリアの更新がされた場合のonChangeと、「登録」ボタンをクリックした際のonClickは、いずれも現時点は空処理の仮設置としています。後ほど開発していきます。

これで一連のコンポーネントのレイアウト作成は完了です。現時点で以下のような動きが実現出来てると思います。

学習記録アプリの動きアニメ

4. DBデータ取得

DBデータを操作するクライアントコンポーネントのレイアウトが出来上がりましたので、ここからは実際にDBデータを操作する機能を作成していきます。まずはDBデータ取得処理です。
これは、Recordsコンポーネントに記述していきます。

まずは、Records.tsxの変更後のコード全文を掲載します。

// /app/components/Records.tsx

"use client";
import { useEffect, useRef, useState } from "react"; //useEffect追加
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogCloseButton,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogOverlay,
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Stack,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  useDisclosure,
  useToast, //追加
  Spinner, //追加
} from "@chakra-ui/react";
import { User } from "firebase/auth"; //追加
import { StudyData } from "../utils/studyData";
import Edit from "./Edit";
import Delete from "./Delete";
import NewEntry from "./NewEntry";

const Records = () => {
  const [email, setEmail] = useState("test@test.com");
  const [learnings, setLearnings] = useState<StudyData[]>([]); //初期値は空に変更
  /*     {
      title: "React",
      time: 15,
      email: email,
    },
    {
      title: "TypeScript",
      time: 10,
      email: email,
    }, */

  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false); //追加
  const [user, setUser] = useState<User | null>(null); //追加
  const toast = useToast(); //追加

  //追加
  /***Firestoreデータ取得***/
  const fetchDb = async (email: string) => {
    setLoading(true);
    try {
      //サーバーコンポーネントの/api/records/read に対してリクエスト
      const res = await fetch(`/api/records/read?email=${email}`);
      const data = await res.json();

      if (res.ok && data.success) {
        console.log("fetchStudies:", email, data);
        setLearnings(data.data); // 成功時にデータをセット
      } else {
        console.error("fetchStudiesError", email, data);
        throw new Error(data.error || "Failed to fetch studies.");
      }
    } catch (err: unknown) {
      console.error("Error in fetchStudies:", err);
      toast({
        //エラー時はChakraUIのToast機能でエラー表示
        title: "データ取得に失敗しました",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  //Firestore確認
  useEffect(() => {
    if (email) {
      fetchDb(email);
      console.log("useEffectFirestore:", email, user);
    }
  }, [user]); // userが更新された時のみ実行

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

  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">
              学習記録
              {/*追加:ローティング中であれば<Spinner />を表示*/}
              {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>
                          <Edit learning={learning} />
                        </Td>
                        <Td>
                          <Delete learning={learning} />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            {/* 追加:ローディング */}
            {!loading && (
              <Box p={5}>
                <div>合計学習時間:{calculateTotalTime()}</div>
              </Box>
            )}

            {/*データ新規登録*/}
            <Box p={25}>
              <NewEntry learnings={learnings} />
            </Box>

            {/* ログアウト*/}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={onOpen}>
                  ログアウト
                </Button>
                <AlertDialog
                  motionPreset="slideInBottom"
                  leastDestructiveRef={cancelRef}
                  onClose={onClose}
                  isOpen={isOpen}
                  isCentered
                >
                  <AlertDialogOverlay />
                  <AlertDialogContent>
                    <AlertDialogHeader>ログアウト</AlertDialogHeader>
                    <AlertDialogCloseButton />
                    <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                    <AlertDialogFooter>
                      <Button ref={cancelRef} onClick={onClose}>
                        Cancel
                      </Button>
                      <Button
                        loadingText="Loading"
                        spinnerPlacement="start"
                        colorScheme="red"
                        ml={3}
                        onClick={() => {}}
                      >
                        ログアウト
                      </Button>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </Stack>
            </Box>

            {/*パスワード更新 */}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={() => {}}>
                  パスワード更新
                </Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};

export default Records;

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

他、Chakra UIの useToast(トースト機能)、Spinner(ローディング中のスピンアニメーション)、Firebaseの認証関係の型定義、Userのインポートを追加しています。

続いてフック定義の箇所です。

// /app/components/Records.tsx

const Records = () => {
  const [email, setEmail] = useState("test@test.com");
  const [learnings, setLearnings] = useState<StudyData[]>([]); //初期値は空に変更
  /*     {
      title: "React",
      time: 15,
      email: email,
    },
    {
      title: "TypeScript",
      time: 10,
      email: email,
    }, */

  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false); //追加
  const [user, setUser] = useState<User | null>(null); //追加
  const toast = useToast(); //追加

まず、学習記録データのステート learningsは、固定値としてましたが、今回FiresoreDBからデータを取得する形に変更しますので、初期値は空にするように変更しています。
次に、新たなステートとして、ローディング状況を管理するloading、ユーザー情報を管理するuser を追加しています。loadingは、boolean、userはFirebase認証のSDKが提供するUserという型定義となります。
その他、Chakra UIのトースト機能である、useToastの定義を追加しています。

続いて、FiestoreDBからデータを取得する処理です。

// /app/components/Records.tsx

//追加
/***Firestoreデータ取得***/
const fetchDb = async (email: string) => {
  //async/awaitによる非同期通信
  setLoading(true); //ローディング中にセット
  try {
    //サーバーコンポーネントの/api/records/read に対してリクエスト
    const res = await fetch(`/api/records/read?email=${email}`); //GETメソッドの為、パラメータ(email)をURLに付与
    const data = await res.json(); //JSONの形式でレスポンスを格納

    if (res.ok && data.success) {
      //処理成功の場合は
      console.log("fetchStudies:", email, data);
      setLearnings(data.data); // learningsにデータをセット
    } else {
      console.error("fetchStudiesError", email, data); //エラーの場合は、
      throw new Error(data.error || "Failed to fetch studies."); //エラーとしてcatch処理に渡す
    }
  } catch (err: unknown) {
    console.error("Error in fetchStudies:", err);
    toast({
      //エラー時はChakraUIのToast機能でエラー表示
      title: "データ取得に失敗しました",
      position: "top",
      status: "error",
      duration: 2000,
      isClosable: true,
    });
  } finally {
    setLoading(false); //最後にローディング状態を解除
  }
};

解説をコード中にコメントで記載してますが、2章で作成したサーバーコンポーネントに対し、データ取得(GET)をリクエストしています。GETメソッドの為、URLにパラメータを渡す形です。
データ取得に成功すれば、setLearningsにより、取得データをlearningsステートに格納しています。エラー時はChakra UI のトースト機能でエラーメッセージを表示させています。

次に、useEffectの箇所です。

// /app/components/Records.tsx

//Firestore確認
useEffect(() => {
  if (email) {//emailが存在すれば
    fetchDb(email);//emailを引数としてfetchDbを実行しDBデータを取得
    console.log("useEffectFirestore:", email, user);
  }
}, [user]); // userが更新された時のみ実行

先程、FirestoreDBからデータを取得する関数、fetchDbを定義しましたが、このfetchDbを実行するトリガーが現時点ではありません。そこで、useEffectを利用します。useEffectは指定した条件下で処理を実行させるHooks,フックです(指定した条件以外は処理をスキップという表現のほうが正しいかも知れません)。条件は式の後ろにある[ ]内で指定します。これは依存配列と呼ばれます。
今回の実装では、依存配列にuserを指定しています。これはログインユーザーを変更した場合、DBデータを取得すると言う動きになります。今後、ユーザー認証を実装しますので、この形としています。

最後は、JSXの箇所です。

// /app/components/Records.tsx

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">
            学習記録
            {/*追加:ローティング中であれば<Spinner />を表示*/}
            {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>
                        <Edit learning={learning} />
                      </Td>
                      <Td>
                        <Delete learning={learning} />
                      </Td>
                    </Tr>
                  ))}
                </Tbody>
              </Table>
            </TableContainer>
          </Box>
          {/* 追加:ローディング */}
          {!loading && (
            <Box p={5}>
              <div>合計学習時間:{calculateTotalTime()}</div>
            </Box>
          )}

          {/*データ新規登録*/}
          <Box p={25}>
            <NewEntry learnings={learnings} />
          </Box>

          {/* ログアウト*/}
          <Box px={25} mb={4}>
            <Stack spacing={3}>
              <Button width="100%" variant="outline" onClick={onOpen}>
                ログアウト
              </Button>
              <AlertDialog
                motionPreset="slideInBottom"
                leastDestructiveRef={cancelRef}
                onClose={onClose}
                isOpen={isOpen}
                isCentered
              >
                <AlertDialogOverlay />
                <AlertDialogContent>
                  <AlertDialogHeader>ログアウト</AlertDialogHeader>
                  <AlertDialogCloseButton />
                  <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                  <AlertDialogFooter>
                    <Button ref={cancelRef} onClick={onClose}>
                      Cancel
                    </Button>
                    <Button
                      loadingText="Loading"
                      spinnerPlacement="start"
                      colorScheme="red"
                      ml={3}
                      onClick={() => {}}
                    >
                      ログアウト
                    </Button>
                  </AlertDialogFooter>
                </AlertDialogContent>
              </AlertDialog>
            </Stack>
          </Box>

          {/*パスワード更新 */}
          <Box px={25} mb={4}>
            <Stack spacing={3}>
              <Button width="100%" variant="outline" onClick={() => {}}>
                パスワード更新
              </Button>
            </Stack>
          </Box>
        </CardBody>
      </Card>
    </Flex>
  </>
);

JSX箇所は大きな変更はありません。Spinner を利用して、loadingステートの状態によってローディング中はスピンアニメーショを表示するよう変更しています。

これでFirestoreからデータを取得し表示出来るようになりました。
現在、FirestoreDBに登録しているデータは、これまで使用していた固定値と同じ為、変わったかどうか分かりづらいですが、console.logでコンソール出力するようにしていましたので、ブラウザの検証ツールで確認することが出来ます。下図の通り、データ取得に成功し、Firestoreのデータが取得出来ていることが分かります。id情報は元々のステートの固定値には設定してませんでしたので、これが存在しているのは、Firestoreからデータ取得していると分かります。

ここまででDBデータの取得、表示まで実装出来ました。
今回の前編はここまでとします。
後編では、DBデータの更新・新規登録・削除機能の実装、ユーザーログイン、サインアップ、パスワード更新・リセット処理の実装、そしてGitHubとの連携とVercelへのデプロイ、AWS Amplifyへのデプロイについてお送りします。

No responses yet

コメントを残す

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

AD




TWITTER


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