【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】NextJS・TypeScript・Apollo Cl…

  2. 【NextJS】Metadata(Head,Title)・Script…

  3. 【NextJS】Hooks-useContext・useReducer…

  4. 【NextJS】Hooks-useReducer

  5. 【NextJS】Hooks-useEffect

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

最近の記事

  1. raspberrypi

制作実績一覧

  1. Checkeys