【NextJS】Firestore

1. 概要

前回はLocal Storageの使い方についてでした。今回はFirestoreの使い方についてです。

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

2. nodeのインストール

こちらを参考

3. プロジェクトを作成

こちらを参考

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

こちらを参考

npm install firebase

5. ソースコード

※前回より差分のみを記載

5-1-1. .env.local

DB_HOST=localhost
DB_USER=user
DB_PASSWORD=password
DB_NAME=test

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/lib/firebase/firebase.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-3. src/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-4. src/lib/firebase/firestore-helper.ts

import {
  Firestore,
  collection,
  doc,
  CollectionReference,
  DocumentData,
  DocumentReference,
  getDocs,
  getDoc,
  DocumentSnapshot,
  QuerySnapshot,
  setDoc,
  addDoc,
  deleteDoc,
  updateDoc,
} from "firebase/firestore";

import { firestore } from "@/lib/firebase/firebase";
import CreateFirestoreDataConverter from "./firestore-data-converter";

const getCollectionRef = <T extends DocumentData>(
  db: Firestore,
  path: string,
  pathSegments?: string[]
): CollectionReference<T, DocumentData> => {
  if (pathSegments != undefined) {
    return collection(db, path, ...pathSegments).withConverter(
      CreateFirestoreDataConverter<T>()
    );
  } else {
    return collection(db, path).withConverter(
      CreateFirestoreDataConverter<T>()
    );
  }
};

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

const getCollectionData = async <T extends DocumentData>(
  path: string,
  pathSegments?: string[]
): Promise<T[]> => {
  const collectionRef: CollectionReference<T, DocumentData> =
    getCollectionRef<T>(firestore, path, pathSegments);
  const collection: Promise<QuerySnapshot<T, DocumentData>> =
    getDocs(collectionRef);
  const data: T[] = [];
  (await collection).docs.map((doc) => {
    data.push(doc.data());
  });
  return data;
};

const getDocumentData = async <T extends DocumentData>(
  path: string,
  pathSegments: string[]
): Promise<T | undefined> => {
  const docRef: DocumentReference<T, DocumentData> = getDocRef<T>(
    firestore,
    path,
    pathSegments
  );
  const doc: Promise<DocumentSnapshot<T, DocumentData>> = getDoc(docRef);
  return (await doc).data();
};

const addCollectioin = async <T extends DocumentData>(
  path: string,
  pathSegments: string[],
  params: T
): Promise<DocumentReference<T, DocumentData>> => {
  const collectioinRef: CollectionReference<T, DocumentData> =
    getCollectionRef<T>(firestore, path, pathSegments);
  return await addDoc(collectioinRef, { ...params });
};

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 deleteDocument = async <T extends DocumentData>(
  path: string,
  pathSegments: string[]
): Promise<void> => {
  const docRef: DocumentReference<T, DocumentData> = getDocRef<T>(
    firestore,
    path,
    pathSegments
  );
  await deleteDoc(docRef);
};

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

export {
  getCollectionData,
  getDocumentData,
  addCollectioin,
  addDocument,
  deleteDocument,
  updateDocument,
};

5-1-5. src/lib/components/msg-snackbar.tsx

import React from "react";
import { Snackbar, Alert, Slide, AlertColor } from "@mui/material";
import ClearIcon from "@mui/icons-material/Clear";

interface Props {
  open: boolean;
  severity: AlertColor;
  message: string;
  handleClick: any;
}

const MessageSnackbar = (props: Props) => {
  const { open, severity, message, handleClick } = props;
  return (
    <Snackbar
      open={open}
      autoHideDuration={3000}
      transitionDuration={{ enter: 1000, exit: 2000 }}
      TransitionComponent={Slide}
      TransitionProps={{ enter: true, exit: false }}
      sx={{ height: "10%" }}
      anchorOrigin={{ vertical: "top", horizontal: "center" }}
      onClose={() =>
        handleClick({
          open: false,
          severity,
        })
      }
    >
      <Alert
        severity={severity}
        action={
          <ClearIcon
            onClick={() =>
              handleClick({
                open: false,
                severity,
              })
            }
          />
        }
      >
        {message}
      </Alert>
    </Snackbar>
  );
};

export default MessageSnackbar;

5-1-6. src/app/components/component07/model.ts

import { FieldValue } from "firebase/firestore";

export type User = {
  userId: string;
  name: string;
  createdAt: FieldValue;
};

export type Booking = {
  place: string;
  price: number;
  createdAt: FieldValue;
};

export const COLLECTION_NAME_USERS = "users";
export const COLLECTION_NAME_USERID = "test";
export const SUB_COLLECTION_NAME_BOOKINGS = "bookings";

5-1-7. src/app/components/component07/regist-data.tsx

"use client";

import { serverTimestamp } from "firebase/firestore";
import { addDocument } from "@/lib/firebase/firestore-helper";
import { Button, Stack, TextField } from "@mui/material";
import { ChangeEvent, useState } from "react";
import {
  Booking,
  COLLECTION_NAME_USERID,
  COLLECTION_NAME_USERS,
  SUB_COLLECTION_NAME_BOOKINGS,
} from "./model";

const RegistData = () => {
  const [place, setPlace] = useState<string>("");
  const [price, setPrice] = useState<number>(0);

  const handleAdd = async () => {
    await addDocument<Booking>(
      COLLECTION_NAME_USERS,
      [COLLECTION_NAME_USERID, SUB_COLLECTION_NAME_BOOKINGS, place],
      { place, price, createdAt: serverTimestamp() }
    );
    setPlace("");
    setPrice(0);
  };

  const handlePlace = (
    e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    e.preventDefault();
    setPlace(e.currentTarget.value);
  };

  const handlePrice = (
    e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    e.preventDefault();
    setPrice(Number(e.currentTarget.value));
  };

  return (
    <Stack direction="row" spacing={1} sx={{ width: "100%" }}>
      <TextField
        size="small"
        label="Place"
        value={place}
        onChange={(e) => handlePlace(e)}
      />
      <TextField
        size="small"
        label="Price"
        type="number"
        InputLabelProps={{ shrink: true }}
        value={price}
        onChange={(e) => handlePrice(e)}
      />
      <Button
        variant="contained"
        size="medium"
        color="primary"
        onClick={() => handleAdd()}
      >
        Add
      </Button>
    </Stack>
  );
};

export default RegistData;

5-1-8. src/app/components/component07/display-data.tsx

"use client";

import { useEffect, useState } from "react";
import { AlertColor, Button } from "@mui/material";

import scss from "./page.module.scss";
import {
  getDocumentData,
  getCollectionData,
  deleteDocument,
} from "@/lib/firebase/firestore-helper";
import {
  User,
  Booking,
  COLLECTION_NAME_USERS,
  COLLECTION_NAME_USERID,
  SUB_COLLECTION_NAME_BOOKINGS,
} from "./model";
import MessageSnackbar from "@/lib/components/msg-snackbar";

interface SnackbarState {
  open: boolean;
  severity: AlertColor;
}

const DisplayData = () => {
  const [data, setData] = useState<Booking[]>();
  useEffect(() => {
    const getData = async () => {
      const data: Booking[] = await getCollectionData<Booking>(
        COLLECTION_NAME_USERS,
        [COLLECTION_NAME_USERID, SUB_COLLECTION_NAME_BOOKINGS]
      );
      setData(data);
    };
    getData();
  }, [data]);

  const handleDelete = async (booking: Booking) => {
    await deleteDocument<Booking>(COLLECTION_NAME_USERS, [
      COLLECTION_NAME_USERID,
      SUB_COLLECTION_NAME_BOOKINGS,
      booking.place,
    ]);
  };

  const [msg, setMsg] = useState<string>("");
  const [state, setState] = useState<SnackbarState>({
    open: false,
    severity: "success",
  });
  const { open, severity } = state;
  const handleClick = (booking: Booking, newState: SnackbarState) => {
    setMsg(`${booking.place}:${booking.price}`);
    setState({ ...newState });
  };

  return (
    <div>
      <table>
        <thead>
          <tr>
            <th>Place</th>
            <th>Price</th>
            <th>Delete</th>
          </tr>
        </thead>
        <tbody>
          {data?.map((booking: Booking, i: number) => (
            <tr key={i}>
              <td
                onClick={() =>
                  handleClick(booking, {
                    open: true,
                    severity: "success",
                  })
                }
              >
                {booking.place}
              </td>
              <td
                className={scss.td_right}
                onClick={() =>
                  handleClick(booking, {
                    open: true,
                    severity: "success",
                  })
                }
              >
                {booking.price}
              </td>
              <td className={scss.td_center}>
                <Button
                  variant="contained"
                  size="small"
                  color="primary"
                  onClick={() => handleDelete(booking)}
                >
                  Delete
                </Button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      {severity === "success" ? (
        <MessageSnackbar
          open={open}
          severity="success"
          message={msg}
          handleClick={handleClick}
        />
      ) : (
        <></>
      )}
    </div>
  );
};

export default DisplayData;

5-1-9. src/app/components/component07/page.module.scss

.component {
  color: blue;
  & ul {
    margin-left: 20px;
    & li {
      list-style: disc;
    }
  }
  & table {
    border: 1px solid;
    width: 80%;
    & tr,
    td {
      border: 1px solid;
      padding: 5px;
      cursor: pointer;
    }
    & th {
      border: 1px solid;
      padding: 5px;
      text-align: center;
      background-color: lavender;
    }
    & .td_right {
      text-align: right;
    }
    & .td_center {
      text-align: center;
    }
  }
}

5-1-10. src/app/components/component07/page.tsx

import GoBack from "@/lib/components/go-back";
import scss from "./page.module.scss";
import RegistData from "./regist-data";
import DisplayData from "./display-data";

const Component07 = () => {
  return (
    <div className={scss.component}>
      <GoBack />
      <br />
      <br />
      <ul>
        <li>Firestore</li>
      </ul>
      <RegistData />
      <br />
      <DisplayData />
    </div>
  );
};

export default Component07;

5-1-11. src/app/components/page.tsx

"use client";

import React from "react";
import { Link } from "@mui/material";

import scss from "./page.module.scss";

const Components = () => {
  return (
    <div className={scss.components}>
      <ul>
        <li>
          <Link href="/components/component01" underline="hover">
            Component01
          </Link>
        </li>
        <li>
          <Link href="/components/component02" underline="hover">
            Component02
          </Link>
        </li>
        <li>
          <Link href="/components/component03" underline="hover">
            Component03
          </Link>
        </li>
        <li>
          <Link href="/components/component04" underline="hover">
            Component04
          </Link>
        </li>
        <li>
          <Link href="/components/component05" underline="hover">
            Component05
          </Link>
        </li>
        <li>
          <Link href="/components/component06" underline="hover">
            Component06
          </Link>
        </li>
        <li>
          <Link href="/components/component07" underline="hover">
            Component07
          </Link>
        </li>
      </ul>
    </div>
  );
};

export default Components;

6. サーバーを起動

npm run dev

7. ブラウザで確認

  • http://localhost:3000

8. ディレクトリの構造

.
├── README.md
├── next-env.d.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│   ├── js
│   │   └── script.js
│   ├── next.svg
│   └── vercel.svg
├── src
│   ├── app
│   │   ├── components
│   │   │   ├── component01
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── component02
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── component03
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── component04
│   │   │   │   ├── checkbox-demo.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   ├── page.tsx
│   │   │   │   ├── radio-demo.tsx
│   │   │   │   └── select-demo.tsx
│   │   │   ├── component05
│   │   │   │   ├── canvas.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── component06
│   │   │   │   ├── my-local-storage.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── component07
│   │   │   │   ├── display-data.tsx
│   │   │   │   ├── model.ts
│   │   │   │   ├── page.module.scss
│   │   │   │   ├── page.tsx
│   │   │   │   └── regist-data.tsx
│   │   │   ├── page.module.scss
│   │   │   └── page.tsx
│   │   ├── events
│   │   │   ├── event01
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── page.module.scss
│   │   │   └── page.tsx
│   │   ├── favicon.ico
│   │   ├── globals.css
│   │   ├── globals.scss
│   │   ├── hooks
│   │   │   ├── hook01
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── hook02
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── hook03
│   │   │   │   ├── child.tsx
│   │   │   │   ├── counter-provider.tsx
│   │   │   │   ├── grandchild.tsx
│   │   │   │   ├── myself.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── hook04
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── hook05
│   │   │   │   ├── child.tsx
│   │   │   │   ├── counter-provider.tsx
│   │   │   │   ├── grandchild.tsx
│   │   │   │   ├── myself.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── hook06
│   │   │   │   ├── child.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   ├── page.tsx
│   │   │   │   ├── play-provider.tsx
│   │   │   │   ├── text-box.tsx
│   │   │   │   └── video-player.tsx
│   │   │   ├── page.module.scss
│   │   │   └── page.tsx
│   │   ├── layout.module.scss
│   │   ├── layout.tsx
│   │   ├── nextjs
│   │   │   ├── nextjs01
│   │   │   │   ├── child
│   │   │   │   │   ├── client-page.tsx
│   │   │   │   │   ├── metadata.ts
│   │   │   │   │   └── page.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── nextjs02
│   │   │   │   ├── [...slug]
│   │   │   │   │   └── page.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   ├── page.tsx
│   │   │   │   └── shop
│   │   │   │       └── [id]
│   │   │   │           └── page.tsx
│   │   │   ├── nextjs03
│   │   │   │   ├── fetch-image.tsx
│   │   │   │   ├── get-image.tsx
│   │   │   │   ├── loading.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── nextjs04
│   │   │   │   ├── actions.ts
│   │   │   │   ├── list.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   ├── page.tsx
│   │   │   │   └── register-form.tsx
│   │   │   ├── nextjs05
│   │   │   │   ├── locale-switcher.tsx
│   │   │   │   ├── metadata.ts
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── nextjs06
│   │   │   │   ├── error.tsx
│   │   │   │   ├── fetch-check.tsx
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── page.module.scss
│   │   │   └── page.tsx
│   │   ├── page.module.scss
│   │   ├── page.tsx
│   │   └── redux
│   │       ├── page.module.scss
│   │       ├── page.tsx
│   │       ├── redux01
│   │       │   ├── child.tsx
│   │       │   ├── counter-slice.ts
│   │       │   ├── grandchild.tsx
│   │       │   ├── hooks.ts
│   │       │   ├── myself.tsx
│   │       │   ├── page.module.scss
│   │       │   ├── page.tsx
│   │       │   └── store.ts
│   │       └── redux02
│   │           ├── child.tsx
│   │           ├── hooks.ts
│   │           ├── image-box.tsx
│   │           ├── image-slice.ts
│   │           ├── page.module.scss
│   │           ├── page.tsx
│   │           ├── store.ts
│   │           ├── text-box.tsx
│   │           └── text-slice.ts
│   ├── lib
│   │   ├── common
│   │   │   ├── db
│   │   │   │   ├── pool-config.ts
│   │   │   │   └── pool.ts
│   │   │   ├── definitions.ts
│   │   │   ├── i18n
│   │   │   │   ├── dictionaries
│   │   │   │   │   ├── cn.json
│   │   │   │   │   ├── en.json
│   │   │   │   │   ├── ja.json
│   │   │   │   │   └── ko.json
│   │   │   │   ├── dictionaries.ts
│   │   │   │   └── i18n-config.ts
│   │   │   └── sidebar-links.tsx
│   │   ├── components
│   │   │   ├── alert-snackbar.tsx
│   │   │   ├── go-back.tsx
│   │   │   ├── msg-snackbar.tsx
│   │   │   └── spacer.tsx
│   │   ├── firebase
│   │   │   ├── firebase.ts
│   │   │   ├── firestore-data-converter.ts
│   │   │   └── firestore-helper.ts
│   │   ├── footer.tsx
│   │   ├── header.tsx
│   │   ├── sidebar.tsx
│   │   └── utils
│   │       └── util.ts
│   └── scss
│       └── common
│           ├── _index.scss
│           ├── _mixin.scss
│           ├── _mq.scss
│           └── _variables.scss
├── tailwind.config.ts
└── tsconfig.json

45 directories, 145 files

9. 備考

今回はFirestoreの使い方についてでした。

10. 参考

投稿者プロフィール

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

関連記事

  1. 【NextJS】Error Handling

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

  3. 【NextJS】Local Storage

  4. 【NextJS】Streaming with Suspense

  5. 【NextJS】Redux

  6. 【NextJS】Server Actions with MySQL

最近の記事

  1. AWS
  2. AWS
  3. AWS
  4. AWS
  5. flutter

制作実績一覧

  1. Checkeys