【NextJS】ChatApp with Realtime updates(onSnapshot) On Cloud Firestore

1. 概要

前回はSocketIOを使い、シンプルなチャットアプリを作る内容でした。

今回はCloud Firestoreのリアルタイムアップデート(onSnapshot)を使い、シンプルなチャットアプリを作る内容となります。

対象としては開発を1年程やってて自分で最初から開発してみたい方になります。そのため細かい用語などの説明はしません。

2. nodeのインストール

こちらを参考

3. プロジェクトを作成

こちらを参考

{
  rooms: {
    {roomID}:{
      messages: {
        {messageID}:{
          message: string,
          createdAt: timestamp,
          user: { // 【map】
            userId: string,
            age: number
          }
        }
      }
    }
  }
}

4. 必要なライブラリをインストール

下記を実行

npm i firebase uuid

5. ソースコード

5-1-1. .env.local

NEXT_PUBLIC_FIREBASE_API_KEY=[★]
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=[★]
NEXT_PUBLIC_FIREBASE_PROJECT_ID=[★]
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=[★]
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=[★]
NEXT_PUBLIC_FIREBASE_APP_ID=[★]
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=[★]

5-1-2. src/app/lib/utils/utils.ts

import { FieldValue, Timestamp } from "firebase/firestore";

export const convertFieldValueToStr = (date: FieldValue): string => {
  if (date === null || date === undefined) return "";
  const dateStr: string = new Date((date as Timestamp).toDate()).toUTCString();
  return dateStr;
};

export const stringToObj = <T>(str: string): T => {
  return JSON.parse(str);
};

5-1-3. src/app/lib/model/model.ts

import {
  DocumentChangeType,
  DocumentData,
  FieldValue,
} from "firebase/firestore";

export const COLLECTION_NAME_ROOMS = "rooms";
export const COLLECTION_NAME_ROOMID = "myRoom";
export const SUB_COLLECTION_NAME_MESSAGES = "messages";

export type FsData = {
  changeType?: DocumentChangeType;
  docId: string;
  data: DocumentData;
};

export type Room = {
  roomId: string;
};

export type User = {
  userId: string;
  age: number;
};

export type Chat = {
  message: string;
  user: User;
  createdAt: FieldValue;
};

5-1-4. src/app/lib/firebase/firestore.ts

import { initializeApp, FirebaseApp } from "firebase/app";
import { Firestore, getFirestore } from "firebase/firestore";

const firebaseConfig = {
  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_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
};

const app: FirebaseApp = initializeApp(firebaseConfig);

const firestore: Firestore = getFirestore(app);

export { firestore };

5-1-5. src/app/lib/firebase/firestore-data-converter.ts

import {
  DocumentData,
  FirestoreDataConverter,
  QueryDocumentSnapshot,
  SnapshotOptions,
  WithFieldValue,
} from "firebase/firestore";

const CreateFirestoreDataConverter = <
  T extends DocumentData
>(): FirestoreDataConverter<T> => {
  return {
    toFirestore(data: WithFieldValue<T>): DocumentData {
      return data;
    },
    fromFirestore(
      snapshot: QueryDocumentSnapshot<T>,
      options: SnapshotOptions
    ): T {
      return snapshot.data(options);
    },
  };
};

export default CreateFirestoreDataConverter;

5-1-6. src/app/lib/firebase/firestore-helper.ts

import {
  Firestore,
  collection,
  doc,
  DocumentData,
  DocumentReference,
  QuerySnapshot,
  setDoc,
  onSnapshot,
  query,
  orderBy,
  Query,
  QueryDocumentSnapshot,
  OrderByDirection,
  FieldPath,
  DocumentChangeType,
} from "firebase/firestore";

import { firestore } from "@/app/lib/firebase/firebase";
import CreateFirestoreDataConverter from "@/app/lib/firebase/firestore-data-converter";
import { FsData } from "@/app/lib/model/model";

const getDocRef = <T extends DocumentData>(
  db: Firestore,
  path: string,
  pathSegments: string[]
): DocumentReference<T, DocumentData> => {
  return doc(db, path, ...pathSegments).withConverter(
    CreateFirestoreDataConverter<T>()
  );
};

const addDocument = async <T extends DocumentData>(
  path: string,
  pathSegments: string[],
  params: T
): Promise<void> => {
  const docRef: DocumentReference<T, DocumentData> = getDocRef<T>(
    firestore,
    path,
    pathSegments
  );
  await setDoc(docRef, { ...params });
};

const applyQueryFilters = (
  q: Query<DocumentData, DocumentData>,
  { column, sort }: { column?: string | FieldPath; sort?: string }
): Query<DocumentData, DocumentData> => {
  if (sort) {
    q = query(q, orderBy(column as FieldPath, sort as OrderByDirection));
  }
  return q;
};

const getSnapshot = (
  path: string,
  pathSegments: string[],
  cb,
  filters = {}
) => {
  if (typeof cb !== "function") {
    console.log("Error: The callback parameter is not a function.");
    return;
  }
  let q: Query<DocumentData, DocumentData> = query(
    collection(firestore, path, ...pathSegments)
  );
  q = applyQueryFilters(q, filters);
  const unsubscribe = onSnapshot(
    q,
    (querySnapshot: QuerySnapshot<DocumentData, DocumentData>) => {
      const results: FsData[] = querySnapshot.docs.map(
        (doc: QueryDocumentSnapshot<DocumentData, DocumentData>): FsData => {
          const fsData: FsData = {
            docId: doc.id,
            data: doc.data(),
          };
          return fsData;
        }
      );
      cb(results);
    }
  );
  return unsubscribe;
};

const getSnapshotChange = (
  path: string,
  pathSegments: string[],
  cb,
  filters = {}
) => {
  if (typeof cb !== "function") {
    console.log("Error: The callback parameter is not a function.");
    return;
  }
  let q: Query<DocumentData, DocumentData> = query(
    collection(firestore, path, ...pathSegments)
  );
  q = applyQueryFilters(q, filters);
  const unsubscribe = onSnapshot(
    q,
    (querySnapshot: QuerySnapshot<DocumentData, DocumentData>) => {
      const results: FsData[] = [];
      for (const doc of querySnapshot.docChanges()) {
        if (doc.type === ("added" as DocumentChangeType)) {
          const fsData: FsData = {
            changeType: doc.type,
            docId: doc.doc.id,
            data: doc.doc.data(),
          };
          results.push(fsData);
        }
      }
      cb(results);
    }
  );
  return unsubscribe;
};

export { addDocument, getSnapshot, getSnapshotChange };

5-1-7. src/app/components/view-data.tsx

"use client";

import { useEffect, useState } from "react";

import { getSnapshotChange } from "@/app/lib/firebase/firestore-helper";
import {
  Chat,
  COLLECTION_NAME_ROOMS,
  COLLECTION_NAME_ROOMID,
  SUB_COLLECTION_NAME_MESSAGES,
  User,
  FsData,
} from "@/app/lib/model/model";
import { DocumentChangeType, Unsubscribe } from "firebase/firestore";
import { stringToObj } from "@/app/lib/utils/utils";

const ViewData = () => {
  const initialFilters = { column: "createdAt", sort: "asc" };
  const initialChat: Chat[] = [];
  const [chats, setChats] = useState<Chat[]>(initialChat);
  const [filters, setFilters] = useState(initialFilters);

  useEffect(() => {
    const unsubscribe: Unsubscribe | undefined = getSnapshotChange(
      COLLECTION_NAME_ROOMS,
      [COLLECTION_NAME_ROOMID, SUB_COLLECTION_NAME_MESSAGES],
      (fsData: FsData[]) => {
        const newChat: Chat[] = [];
        for (const fs of fsData) {
          if (fs.changeType === ("added" as DocumentChangeType)) {
            const user: User = stringToObj<User>(JSON.stringify(fs.data.user));
            const chat: Chat = {
              message: fs.data.message,
              user,
              createdAt: fs.data.createdAt,
            };
            newChat.push(chat);
          }
        }
        if (newChat && newChat.length > 0) {
          setChats((chats) => [...chats, ...newChat]);
        }
      },
      filters
    );
    return () => {
      unsubscribe!();
    };
  }, [filters]);

  return (
    <div>
      <ul>
        {chats?.map((msg: Chat, idx: number) => (
          <li key={idx}>
            [{msg.user.userId}({msg.user.age})] {msg.message}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default ViewData;

5-1-8. src/app/components/chat-form.tsx

"use client";

import { FormEvent, useState } from "react";
import { serverTimestamp } from "firebase/firestore";
import { v4 as uuidv4 } from "uuid";
import {
  COLLECTION_NAME_ROOMID,
  COLLECTION_NAME_ROOMS,
  Chat,
  SUB_COLLECTION_NAME_MESSAGES,
  User,
} from "@/app/lib/model/model";
import { addDocument } from "@/app/lib/firebase/firestore-helper";

const ChatForm = () => {
  const [msg, setMsg] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);

  const chat = async (targetUser: User | null, newMsg: string) => {
    const message: Chat = {
      message: newMsg,
      user: targetUser!,
      createdAt: serverTimestamp(),
    };

    const messageId: string = uuidv4();
    await addDocument<Chat>(
      COLLECTION_NAME_ROOMS,
      [COLLECTION_NAME_ROOMID, SUB_COLLECTION_NAME_MESSAGES, messageId],
      message
    );

    setIsLoading(false);
    setMsg("");
  };

  const sendMsg = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (user === null) {
      alert(`Please join`);
      return;
    }
    setIsLoading(true);
    await chat(user, msg);
  };

  const join = async () => {
    const userId: string = user === null ? uuidv4() : user.userId;
    const age: number =
      user === null ? Math.floor(Math.random() * 10) : user.age;
    setUser({ userId, age });
    await chat({ userId, age }, "joined.");
  };

  const leave = async () => {
    if (user) await chat(user, "leaved.");
    setUser(null);
  };

  return (
    <>
      <button onClick={join}>Join</button>
      <button onClick={leave}>Leave</button>
      <p>Status: {user === null ? "Leaved" : "Joined"}</p>
      <form onSubmit={(e) => sendMsg(e)}>
        <input value={msg} onChange={(e) => setMsg(e.target.value)} />
        <button type="submit" disabled={isLoading}>
          Chat
        </button>
      </form>
    </>
  );
};

export default ChatForm;

5-1-9. src/app/page.tsx

import styles from "./page.module.css";
import ViewData from "@/app/components/view-data";
import ChatForm from "@/app/components/chat-form";

export default function Home() {
  return (
    <div className={styles.page}>
      <main className={styles.main}>
        <ViewData />
        <ChatForm />
      </main>
    </div>
  );
}

6. サーバーを起動

npm run dev

7. ブラウザで確認

  • http://localhost:3000
    • ブラウザを3つ立ち上げて確認

8. ディレクトリの構造

.
├── README.md
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│   ├── file.svg
│   ├── globe.svg
│   ├── next.svg
│   ├── vercel.svg
│   └── window.svg
├── src
│   └── app
│       ├── components
│       │   ├── chat-form.tsx
│       │   └── view-data.tsx
│       ├── favicon.ico
│       ├── fonts
│       │   ├── GeistMonoVF.woff
│       │   └── GeistVF.woff
│       ├── globals.css
│       ├── layout.tsx
│       ├── lib
│       │   ├── firebase
│       │   │   ├── firebase.ts
│       │   │   ├── firestore-data-converter.ts
│       │   │   └── firestore-helper.ts
│       │   ├── model
│       │   │   └── model.ts
│       │   └── utils
│       │       └── utils.ts
│       ├── page.module.css
│       └── page.tsx
├── tailwind.config.ts
└── tsconfig.json

9 directories, 27 files

9. 備考

今回はCloud Firestoreのリアルタイムアップデート(onSnapshot)を使い、シンプルなチャットアプリを作る内容についてでした。

10. 参考

投稿者プロフィール

Sondon
開発好きなシステムエンジニアです。
卓球にハマってます。

関連記事

  1. 【NextJS】VSCodeにGemini Code Assistを連…

  2. 【NextJS】OAuth authentication with G…

  3. AWS

    【AWS】Deploy Serverless NextJS app w…

  4. 【Next.js】ローカル環境をSSL化してみる

  5. 【NextJS】UI Design using Stitch with…

  6. 【NextJS】Button・IconButton・LoadingBu…

最近の記事

制作実績一覧

  1. Checkeys