Closed34

Firestore を使ってローカルの変更を Web ページに反映するデモを作る

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Web アプリ作成

コマンド
# create-next-app の最新バージョンに更新します。
npm i -g create-next-app
# Next.js プロジェクトを作成します。
npx create-next-app \
  --typescript \
  --tailwind \
  --eslint \
  --src-dir \
  --import-alias "@/*" \
  --use-npm \
  firebase-sync-demo

https://zenn.dev/catnose99/articles/f8a90a1616dfb3#アプリケーション

catnose さんの記事により、App Router が安定するまで Pages Router を使い続けよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

サクッと作ってみる

src/pages/index.tsx
import { useState } from "react";

export default function Home() {
  const [editorContent, setEditorContent] = useState("");

  return (
    <main className="container mx-auto px-4">
      <h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
      <label htmlFor="editor" className="block mb-2">
        Editor
      </label>
      <textarea
        name="editor"
        id="editor"
        rows={30}
        className="border border-gray-400 w-full p-2"
        value={editorContent}
        onChange={(event) => setEditorContent(event.target.value)}
      ></textarea>
    </main>
  );
}
src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;


現状ではテキストエリアがあるだけ

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

変更をコンソールに表示する

src/pages/index.tsx
import { deleteApp, initializeApp } from "firebase/app";
import { collection, getFirestore, onSnapshot } from "firebase/firestore";
import Head from "next/head";
import { useEffect, useState } from "react";

export default function Home() {
  const [editorContent, setEditorContent] = useState("");

  useEffect(() => {
    const firebaseConfig = {
        // ...
    };

    const app = initializeApp(firebaseConfig);
    const db = getFirestore(app);
    const changesCollection = collection(db, "changes");
    const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
      console.log(snapshot);
    });

    return () => {
      unsubscribe();
      deleteApp(app);
    };
  }, []);

  return (
    <main className="container mx-auto px-4">
      <Head>
        <title>Firebase Sync Demo</title>
      </Head>
      <h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
      <label htmlFor="editor" className="block mb-2">
        Editor
      </label>
      <textarea
        name="editor"
        id="editor"
        rows={30}
        className="border border-gray-400 w-full p-2"
        value={editorContent}
        onChange={(event) => setEditorContent(event.target.value)}
      ></textarea>
    </main>
  );
}

現状ではセキュリティルールの設定不足のため permission denied になる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

セキュリティルール

セキュリティルール
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
  	match /changes/{change} {
    	allow read, create: if true;
    }
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

変更の表示

若干コードを変更。

    const unsubscribe = onSnapshot<Change>(changesCollection, (snapshot) => {
      for (const docChange of snapshot.docChanges()) {
        if (docChange.type === "added") {
          console.log(docChange.doc.data());
        }
      }
    });

これで無事に変更内容が表示されるようになった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

CLI の作成

せっかくなので別プロジェクトとして作成しよう。

コマンド
mkdir ~/workspace/firebase-sync-cli
cd ~/workspace/firebase-sync-cli
npm init -y
npm install firebase
npm install -D ts-node @types/node
touch main.ts
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

CLI コーディング

main.ts
import { deleteApp, initializeApp } from "firebase/app";
import { addDoc, collection, doc, getFirestore } from "firebase/firestore";

async function main() {
  const firebaseConfig = {
    // ...
  };

  const app = initializeApp(firebaseConfig);
  const db = getFirestore(app);

  await addDoc(collection(db, "changes"), {
    content: "content",
    expireAt: new Date(Date.now() + 1 * 60 * 60 * 1000),
  });

  await deleteApp(app);
}

main().catch((err) => console.error(err));
コマンド
npx ts-node main.ts

実行すると Web の方でコンソールに内容が表示される。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

変更を反映する。

firebase-sync-demo/src/pages/index.tsx(一部)
    const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
      for (const docChange of snapshot.docChanges()) {
        if (docChange.type === "added") {
          type Change = {
            content: string;
          };

          const change = docChange.doc.data() as Change;

          setEditorContent(change.content);
        }
      }
    });


CLI を実行したらテキストエリアに変更が反映された。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

変更が反映されるようになった

firebase-sync-cli/main.ts
import { deleteApp, initializeApp } from "firebase/app";
import { addDoc, collection, doc, getFirestore } from "firebase/firestore";
import { readFile, watch } from "fs/promises";

async function main() {
  const firebaseConfig = {
    // ...
  };

  const app = initializeApp(firebaseConfig);
  const db = getFirestore(app);
  const watcher = watch(__dirname);

  for await (const event of watcher) {
    if (event.filename === "main.ts" && event.eventType === "change") {
      await addDoc(collection(db, "changes"), {
        content: await readFile(event.filename, "utf-8"),
        expireAt: new Date(Date.now() + 1 * 60 * 60 * 1000),
      });
    }
  }

  await deleteApp(app);
}

main().catch((err) => console.error(err));
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

40 分近く経っている

onSnapshot() を呼び出す前にコレクションを削除したいと思い、どうやるのが良いんだろうと考えていたら 40 分近く経過してしまった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

tRPC セットアップ

そろそろサーバー側の処理が必要になりそうなのでセットアップする。

コマンド
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query@^4.0.0 zod
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

なんともう 2 時間も経っている

どうやって変更履歴を削除してから onSnapshot を呼び出そうかと試行錯誤してたらこんなに時間が経っていた。

https://zenn.dev/counterworks/articles/react-hook-form-async-default-values

結局はこのような形に落ち着いた。

src/pages/index.tsx
import { trpc } from "@/utils/trpc";
import { deleteApp, initializeApp } from "firebase/app";
import {
  collection,
  deleteDoc,
  doc,
  getFirestore,
  onSnapshot,
} from "firebase/firestore";
import Head from "next/head";
import { useEffect, useState } from "react";

export default function RemoveAllChanges() {
  const mutation = trpc.removeAllChanges.useMutation();

  useEffect(() => {
    mutation.mutate();
  }, []);

  if (!mutation.isSuccess) {
    return null;
  }

  return <Home></Home>;
}

function Home() {
  const [editorContent, setEditorContent] = useState("");

  useEffect(() => {
    const firebaseConfig = {
      // ...
    };

    const app = initializeApp(firebaseConfig);
    const db = getFirestore(app);
    const changesCollection = collection(db, "changes");
    const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
      for (const docChange of snapshot.docChanges()) {
        if (docChange.type === "added") {
          type Change = {
            content: string;
          };

          const change = docChange.doc.data() as Change;

          setEditorContent(change.content);
          deleteDoc(doc(db, "changes", docChange.doc.id));
        }
      }
    });

    return () => {
      unsubscribe();
    };
  }, []);

  return (
    <main className="container mx-auto px-4">
      <Head>
        <title>Firebase Sync Demo</title>
      </Head>
      <h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
      <label htmlFor="editor" className="block mb-2">
        Editor
      </label>
      <textarea
        name="editor"
        id="editor"
        rows={30}
        className="border border-gray-400 w-full p-2"
        value={editorContent}
        onChange={(event) => setEditorContent(event.target.value)}
      ></textarea>
    </main>
  );
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

細かいことを気にしないなら

こんな感じの実装でも十分そうだ。

firebase-sync-demo/src/pages/index.tsx
import { deleteApp, initializeApp } from "firebase/app";
import {
  collection,
  deleteDoc,
  doc,
  getDocs,
  getFirestore,
  onSnapshot,
} from "firebase/firestore";
import Head from "next/head";
import { useEffect, useState } from "react";

const firebaseConfig = {
  // ...
};

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const changesCollection = collection(db, "changes");

export default function DeleteAllChanges() {
  const [isDeleted, setIsDeleted] = useState(false);

  useEffect(() => {
    (async function () {
      for (;;) {
        const changesSnapshot = await getDocs(changesCollection);

        if (changesSnapshot.empty) {
          break;
        }

        for (const change of changesSnapshot.docs) {
          await deleteDoc(doc(changesCollection, change.id));
        }
      }

      setIsDeleted(true);
    })();
  });

  if (!isDeleted) {
    return null;
  }

  return <Home></Home>;
}

function Home() {
  const [editorContent, setEditorContent] = useState("");

  useEffect(() => {
    const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
      for (const docChange of snapshot.docChanges()) {
        if (docChange.type === "added") {
          type Change = {
            content: string;
          };

          const change = docChange.doc.data() as Change;

          setEditorContent(change.content);
          deleteDoc(doc(db, "changes", docChange.doc.id));
        }
      }
    });

    return () => {
      unsubscribe();
    };
  }, []);

  return (
    <main className="container mx-auto px-4">
      <Head>
        <title>Firebase Sync Demo</title>
      </Head>
      <h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
      <label htmlFor="editor" className="block mb-2">
        Editor
      </label>
      <textarea
        name="editor"
        id="editor"
        rows={30}
        className="border border-gray-400 w-full p-2"
        value={editorContent}
        onChange={(event) => setEditorContent(event.target.value)}
      ></textarea>
    </main>
  );
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

タブを追加してみる

firebase-sync-demo/src/pages/index.tsx
import { Tab } from "@headlessui/react";
import { deleteApp, initializeApp } from "firebase/app";
import {
  collection,
  deleteDoc,
  doc,
  getDocs,
  getFirestore,
  onSnapshot,
} from "firebase/firestore";
import Head from "next/head";
import { useEffect, useState } from "react";

const firebaseConfig = {
  // ..
};

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const changesCollection = collection(db, "changes");

export default function DeleteAllChanges() {
  const [isDeleted, setIsDeleted] = useState(false);

  useEffect(() => {
    (async function () {
      for (;;) {
        const changesSnapshot = await getDocs(changesCollection);

        if (changesSnapshot.empty) {
          break;
        }

        for (const change of changesSnapshot.docs) {
          await deleteDoc(doc(changesCollection, change.id));
        }
      }

      setIsDeleted(true);
    })();
  });

  if (!isDeleted) {
    return null;
  }

  return <Home></Home>;
}

type Source = {
  name: string;
  content: string;
};

function Home() {
  const [sources, setSources] = useState<Source[]>([
    { name: "Untitled1", content: "a" },
    { name: "Untitled2", content: "b" },
    { name: "Untitled3", content: "c" },
  ]);

  const [selectedIndex, setSelectedIndex] = useState(0);

  useEffect(() => {
    const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
      for (const docChange of snapshot.docChanges()) {
        if (docChange.type === "added") {
          type Change = {
            content: string;
          };

          const change = docChange.doc.data() as Change;

          setEditorContent(change.content);
          deleteDoc(doc(db, "changes", docChange.doc.id));
        }
      }
    });

    return () => {
      unsubscribe();
    };
  }, []);

  return (
    <main className="container mx-auto px-4">
      <Head>
        <title>Firebase Sync Demo</title>
      </Head>
      <h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
      <Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
        <Tab.List className="mb-4">
          {sources.map((source) => (
            <Tab
              key={source.name}
              className="px-2 py-1 mr-2 border border-gray-500 ui-selected:bg-gray-500 ui-selected:text-white ui-not-selected:bg-white ui-not-selected:text-black"
            >
              {source.name}
            </Tab>
          ))}
        </Tab.List>
      </Tab.Group>
      <label htmlFor="editor" className="block mb-2">
        Editor
      </label>
      <textarea
        name="editor"
        id="editor"
        rows={30}
        className="border border-gray-400 w-full p-2"
        value={sources[selectedIndex].content}
        onChange={(event) =>
          setSources((sources) => [
            ...sources.slice(0, selectedIndex),
            {
              ...sources[selectedIndex],
              content: event.target.value,
            },
            ...sources.slice(selectedIndex + 1),
          ])
        }
      ></textarea>
    </main>
  );
}


タブを選択すると別々のコンテンツが表示されるようになった。

Headless UI 初めて使ったが好みかも知れない。

MUI Base も気になる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

1/5 (金) はここまで

今日は 4 時間くらい時間を費やしたがやろうと思っていたことが全然できなかった。

まあ、色々と初めてのことにチャレンジしながら進めるとこんなもんか。

Firestore すごく便利で安くて素晴らしいんだけどコレクションの一括削除とかが簡単にできなくて、またその方法も広く使われているものがなくて辛い。

DynamoDB の時にも感じたが RDB 以外のデータストアを使おうとするとめちゃくちゃハードモードになる。

そういえば changes というコレクション名を使っていたが、channels/{channel}/messages みたいな構成にした方がやりやすそうなのでそのような方向性で考えてみよう。

このスクラップは4ヶ月前にクローズされました