【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】FullCalendar

  2. 【NextJS】Checkbox・Radio Group

  3. 【NextJS】Redux

  4. 【NextJS】日程調整からグループ分け、精算までPankoが便利です…

  5. 【NextJS】Cognito with Amplify(Gen2)+…

  6. 【NextJS】Server Actions with MySQL

最近の記事

  1. raspberrypi

制作実績一覧

  1. Checkeys