🐯

Firebase Emulatorを起動してReactアプリから接続

2024/05/12に公開

バックエンドにFirebaseに採用しているプロジェクトでは、ローカルで開発をする際にFirebase Emulatorを使うと便利です。
今回、フロントエンドにReact、state管理はContextを採用しているプロジェクトで導入時に少しつまづいたので、やり方をまとめてみます。

概要

Firebase CLIのインストールやFirebaseプロジェクトの作成、フロント側のReactアプリの作成は完了している前提です。

以下の流れでEmulatorを導入しています。

  1. Emulatorのダウンロードと初期設定
  2. Firebaseの初期化とFirebaseProviderの作成
  3. Context経由でFirebaseを利用

以下の実装例を載せています。

  • Authenticationを使ってログイン処理
  • Firestoreからデータ取得

使用しているライブラリのバージョンは以下の通りです。

"firebase": "^9.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^5.3.0",
"react-scripts": "^5.0.1",

Emulatorのダウンロードと初期設定

こちらのコマンドでEmulatorのダウンロードが始まります。

firebase init emulators

対話形式でどれを使うか、ポートは何番にするかを指定します。
今回はAuthentication, Firestoreの2つを選択しポートはデフォルトの番号にしました。

完了するとfirebase.jsonに以下の内容が追加されるはずです。

"emulators": {
  "auth": {
    "port": 9099
  },
  "firestore": {
    "port": 8080
  },
  "ui": {
    "enabled": true
  },
  "singleProjectMode": true
}

あとは、以下のコマンドで起動できます。

firebase emulators:start
起動後
i  emulators: Starting emulators: auth, firestore, hosting
⚠  hosting: Hosting Emulator unable to start on port 5000, starting on 5002 instead.
i  firestore: Firestore Emulator logging to firestore-debug.log
✔  firestore: Firestore Emulator UI websocket is running on 9150.
i  hosting[trarec-fe452]: Serving hosting files from: build
✔  hosting[trarec-fe452]: Local server: http://127.0.0.1:5002
i  ui: Emulator UI logging to ui-debug.log

┌─────────────────────────────────────────────────────────────┐
│ ✔  All emulators ready! It is now safe to connect your app. │
│ i  View Emulator UI at http://127.0.0.1:4000/               │
└─────────────────────────────────────────────────────────────┘

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ 127.0.0.1:9099 │ http://127.0.0.1:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ 127.0.0.1:8080 │ http://127.0.0.1:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ 127.0.0.1:5002 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘

Firebaseの初期化とFirebaseProviderの作成

FirebaseProvider.tsxなどの名前でファイルを作成します。(ファイル名はなんでもOK)
Firebaseの初期化、Context, Providerを作成してauth, dbを定義します。

import { initializeApp } from "firebase/app";
import { Auth, connectAuthEmulator, getAuth } from "firebase/auth";
import { Firestore, connectFirestoreEmulator, getFirestore } from "firebase/firestore";
import { ReactNode, createContext, useEffect } from "react";

initializeApp({
  apiKey: process.env.REACT_APP_APIKEY,
  authDomain: process.env.REACT_APP_AUTHDOMAIN,
  projectId: process.env.REACT_APP_PROJECTID,
});

export type FirebaseContextType = {
  auth: Auth;
  db: Firestore;
};

export const FirebaseContext = createContext<FirebaseContextType>({} as FirebaseContextType);

export const FirebaseProvider = (props: { children: ReactNode }) => {
  const { children } = props;

  const auth = getAuth();
  const db = getFirestore();

  useEffect(() => {
    if (window.location.hostname === "localhost") {
      connectAuthEmulator(auth, "http://localhost:9099", { disableWarnings: true });
      connectFirestoreEmulator(db, "localhost", 8080);
    }
  }, []);

  return <FirebaseContext.Provider value={{ auth, db }}>{children}</FirebaseContext.Provider>;
};

initializeApp の引数でFirebaseプロジェクトのapiKey等を渡す必要があるので、Firebaseコンソールからコピーしてきたものを.envで管理しています。

上位の階層で作成したProviderでラップしておきます。今回は、App.tsxに書いておきました。

function App() {
  return (
    <ChakraProvider theme={theme}>
      <FirebaseProvider>
        <BrowserRouter>
          <Router />
        </BrowserRouter>
      </FirebaseProvider>
    </ChakraProvider>
  );
}

export default App;

初めuseEffect を使っておらず、複数回接続してしまっていたようで以下のエラーが発生していました… 😅

assert.ts:136 Uncaught FirebaseError: Firebase: Error (auth/emulator-config-failed).
    at createErrorInternal (assert.ts:136:1)
    at _assert (assert.ts:167:1)
    at connectAuthEmulator (emulator.ts:50:1)
    at FirebaseProvider (FirebaseProvider.tsx:27:1)
    at renderWithHooks (react-dom.development.js:15486:1)
    at updateFunctionComponent (react-dom.development.js:19617:1)
    at beginWork (react-dom.development.js:21640:1)
    at HTMLUnknownElement.callCallback (react-dom.development.js:4164:1)
    at Object.invokeGuardedCallbackDev (react-dom.development.js:4213:1)
    at invokeGuardedCallback (react-dom.development.js:4277:1)

useEffect を使って1回だけ接続するようにしたことで解消しました!

Context経由でFirebaseを利用

コンポーネントでContextの値を使えばAuthenticationやFirestoreを利用できます。

Authenticationを使ってログイン処理

再利用しやすいようにカスタムHookを定義しました。
入力されたmail, passwordをもとにログインする想定のコードです。

export const useAuth = () => {
  const history = useHistory();
  const { showMessage } = useMessage();
  const { setLoginUser } = useLoginUser();
  const { auth } = useContext(FirebaseContext);

  const [loading, setLoading] = useState(false);

  const login = useCallback(
    (mail: string, password: string) => {
      setLoading(true);

      setPersistence(auth, browserSessionPersistence).then(() => {
        signInWithEmailAndPassword(auth, mail, password)
          .then((userCredential) => {
            const userObject = userCredential.user;
            const uid = userObject.uid ? userObject.uid : "";
            const email = userObject.email ? userObject.email : "";
            setLoginUser({ uid, email });
            showMessage({ title: "ログインしました。", status: "success" });
          })
          .catch((error) => {
            // 省略
          })
          .finally(() => setLoading(false));
      });
    },
    [auth, setLoginUser, showMessage]
  );

return { login };
import { useAuth } from "../../hooks/useAuth";

const onClickLoginSignUp = () => login(mail, pass);

<Button onClick={onClickLoginSignUp} loading={loading} >ログイン</Button>

Firestoreからデータ取得

menusというコレクションから条件を指定してドキュメントを取得する際のコードです。

const { db } = useContext(FirebaseContext);

const [menus, setMenus] = useState<Menu[]>([]);

const getMenus = useCallback(() => {
    console.log("getMenus!");
    setLoading(true);
    let menus: Menu[] = [];
    getDocs(query(collection(db, "menus"), orderBy("createdAt", "asc"), where("uid", "==", loginUser ? loginUser.uid : ""))).then((snapshot) => {
      snapshot.forEach((doc) => {
        const data = doc.data();
        menus.push({
          id: doc.id,
          name: data.name,
          memo: data.memo,
          weight: data.weight,
          weightType: data.weightType,
          count: data.count,
          set: data.set,
        });
      });
      setMenus(menus);
    });
    setLoading(false);
  }, [db, loginUser]);

参考

Firebase公式ドキュメントのEmulatorについての説明です。
Firebase > Firebase ドキュメント > Local Emulator Suite > 構築
https://firebase.google.com/docs/emulator-suite/install_and_configure

ReactのContextについての説明です。
LEARN REACT > STATE の管理 > コンテクストで深くデータを受け渡す
https://ja.react.dev/learn/passing-data-deeply-with-context

ReactのカスタムHookについての説明です。
LEARN REACT > 避難ハッチ > カスタムフックでロジックを再利用する
https://ja.react.dev/learn/reusing-logic-with-custom-hooks

Discussion