環境
- React v18.2.0
- TypeScript v4.9.5
- firebase v9.17.1
- react-query-firebase
目次
セットアップ
firebase init
でローカル環境を作る。方法は各自検索されたし。
設定ファイル
今回はfirestoreとauthのみ使用する。
import { initializeApp } from 'firebase/app';
import {
connectAuthEmulator,
getAuth,
GithubAuthProvider,
GoogleAuthProvider,
} from 'firebase/auth';
import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';
import { Firebaseの環境変数 } from '@/config';
const firebaseConfig = {
// いつもの
};
const firebase = initializeApp(firebaseConfig);
const auth = getAuth();
FIREBASE_EMULATE === 'true' && connectAuthEmulator(auth, 'http://localhost:9009');
const db = getFirestore();
FIREBASE_EMULATE === 'true' && connectFirestoreEmulator(db, 'localhost', 8080);
const firebaseAuthProviders = {
github: new GithubAuthProvider(),
google: new GoogleAuthProvider(),
};
export { firebase, auth, db, firebaseAuthProviders };
エミュレーターを使用する時のポートはfirebase.jsonで後から変更できる。
yarn firebase emulators:start
でデータを永続化せず起動。
yarn firebase emulators:start --import=<保存先ディレクトリ> --export-on-exit
でデータを永続化して起動。
通信の中身を詳しく見ることが出来るので開発環境では基本的にエミュを使用する。
エミュはvscodeとは別でシェルを開いて起動すると良い。
終了する時はポートの一覧が出ているウインドウでctrl+C
firebase関連のファイルは1つに纏めて管理すると散らばらなくて良い。
rulesやindexesの参照位置もfirebase.jsonで変更できる。
認証
目次
認証プロバイダー
SNSログインとログアウト機能
import type { FC, ReactNode } from 'react';
import React, { createContext, useContext } from 'react';
import { signInWithPopup, signOut as firebaseSignOut } from 'firebase/auth';
import { SuspenseFallback } from '@/components/Elements/SuspenseFallback';
import { auth, firebaseAuthProviders } from '@/config/firebase';
import { useFireAuthUser } from '@/features/auth';
import type { FireUser } from '@/features/users';
import { useObserveUserDoc } from '@/hooks/useObserveUserDoc';
import type { User, UserCredential } from 'firebase/auth';
export const useFireAuth = () => {
const { user, isLoading: isAuthLoading } = useFireAuthUser();
const { userDocData, isLoading: isDocLoading } = useObserveUserDoc(user);
const signIn = async (provider: keyof typeof firebaseAuthProviders) => {
const result = await signInWithPopup(auth, firebaseAuthProviders[provider]);
console.log(result);
return result;
};
const signOut = async () => {
await firebaseSignOut(auth);
window.location.assign(window.location.origin as unknown as string);
};
return { user, isAuthLoading, userDocData, isDocLoading, signIn, signOut };
};
const AuthContext = createContext<{
user: User | null;
isAuthLoading: boolean;
userDocData: FireUser | undefined;
isDocLoading: boolean;
signIn: (provider: keyof typeof firebaseAuthProviders) => Promise<UserCredential>;
signOut: () => Promise<void>;
} | null>(null);
export const FireAuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const auth = useFireAuth();
if (auth.isAuthLoading) {
return <SuspenseFallback />;
}
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
};
export const useAuthContext = () => useContext(AuthContext);
認証ユーザーの取得
import { useState, useEffect } from 'react';
import { onAuthStateChanged } from 'firebase/auth';
import { auth } from '@/config/firebase';
import type { User } from 'firebase/auth';
export const useFireAuthUser = () => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
const handleUser = (user: User | null) => {
setUser(user);
setIsLoading(false);
};
const handleError = (error: Error) => {
setError(error);
setIsLoading(false);
};
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, handleUser, handleError);
return unsubscribe;
}, []);
return { user, isLoading, error };
};
認証ユーザーのFirestoreドキュメントの取得
認証されていてドキュメントが無ければ作成する。
ドキュメント取得部分
ドキュメント作成部分
import { useEffect, useState } from 'react';
import type { FireUser } from '@/features/users';
import { useFireUser, useCreateFireUser } from '@/features/users';
import type { User } from 'firebase/auth';
import type { FirestoreError } from 'firebase/firestore';
export const useObserveUserDoc = (
user: User | null
): { userDocData: FireUser | undefined; isLoading: boolean; error: FirestoreError | null } => {
const { data: userDocData, isLoading, error } = useFireUser(user?.uid);
const createFireUser = useCreateFireUser(user);
const [check, setCheck] = useState(false);
useEffect(() => {
if (user && !isLoading && !userDocData && !check) {
setCheck(true);
createFireUser.mutateDTO();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userDocData, isLoading, user]);
return {
userDocData,
isLoading,
error,
};
};
データ取得
目次
カスタムhook
collectionとdocumentどちらもこれで取得できる。
collectionGroupはindexとRulesの設定が必要なためそれらを済ませるのを忘れずに。
import { useEffect, useState } from 'react';
import { onSnapshot } from 'firebase/firestore';
import { formatDoc } from '../utils/format';
import type { Query, DocumentReference, DocumentData, FirestoreError } from 'firebase/firestore';
export const useFirestore = <T>(
docOrQuery: Query<DocumentData> | DocumentReference | undefined
) => {
const [firestore, setFirestore] = useState<{
data: T | undefined;
isLoading: boolean;
error: FirestoreError | null;
}>({
data: undefined,
isLoading: true,
error: null,
});
useEffect(() => {
if (!docOrQuery) {
return;
}
console.log('request');
setFirestore((prev) => ({ ...prev, isLoading: true }));
let unsubscribe: () => void;
if (docOrQuery.type === 'document') {
unsubscribe = onSnapshot(docOrQuery as DocumentReference, {
next(doc) {
if (doc.exists()) {
const updatedData = formatDoc(doc) as unknown as T;
setFirestore({ data: updatedData, isLoading: false, error: null });
} else {
setFirestore((prev) => ({
...prev,
isLoading: false,
error: {
code: 'not-found',
message: 'Document does not exist',
name: 'FirestoreError',
},
}));
unsubscribe;
}
},
error(error) {
setFirestore((prev) => ({ ...prev, isLoading: false, error: error }));
unsubscribe;
},
});
} else {
unsubscribe = onSnapshot(docOrQuery as Query<DocumentData>, {
next(snapshot) {
const updatedData = snapshot.docs.map((doc) => formatDoc(doc)) as unknown as T;
setFirestore({ data: updatedData, isLoading: false, error: null });
},
error(error) {
setFirestore((prev) => ({ ...prev, isLoading: false, error: error }));
unsubscribe;
},
});
}
return unsubscribe;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(docOrQuery)]);
return firestore;
};
このように使用する。
export const usePosts = () => {
const posts = useFirestore<Post[]>(
query(collectionGroup(db, 'posts'), orderBy('createdAt', 'desc'))
);
return posts;
};
export const useFireUser = (uid: string | undefined) => {
const fireUser = useFirestore<FireUser>(uid ? doc(collection(db, 'users'), uid) : undefined);
return fireUser;
};
データが存在しない場合にエラーを投げてもらう方法が分らなかったので自分で作って投げている。
useEffectの第二引数の配列にオブジェクトを渡すと無限ループになるので文字列にしないといけない。
データの整形
import { default as dayjs } from 'dayjs';
import type { Timestamp, DocumentData, QueryDocumentSnapshot } from 'firebase/firestore';
// 実際に画面に表示させる時に使う
export const formatDate = (date: string | number | Date | dayjs.Dayjs) =>
dayjs(date).format('MMMM D, YYYY h:mm A');
export const timestampToDate = (timestamp: Timestamp) => {
return timestamp ? timestamp.toDate() : new Date(0);
};
export const formatDoc = (doc: QueryDocumentSnapshot<DocumentData>) => {
const data = doc.data();
const formatedData = { id: doc.id, ...data } as any;
'createdAt' in data && (formatedData.createdAt = timestampToDate(data.createdAt));
'updatedAt' in data && (formatedData.updatedAt = timestampToDate(data.updatedAt));
return formatedData;
};
idやタイムスタンプをフロントで扱いやすいようにしている。
timestampは一瞬だけデータがnullの瞬間があるためエラー回避のためにDate(0)を入れている。
withConverterは一度に2つ以上使用できず、不正なデータが入っていても通過してしまう等取り回しに難があったため、使わないという結論になった。
型安全はSecurity Rulesで保証すれば良い。
データ操作
目次
react-query-firebaseを使用する。読み取りに関しては上手く動作しなかったがこれは普通に使えたので。
詳しいメソッドの説明は各自リンク先を参照されたし。
データ追加
引数が読み込まれるまでダミーのパスを入れているが気持ち悪い、もっといい感じにできるかも。
import { useFirestoreDocumentMutation } from '@react-query-firebase/firestore';
import { collection, doc } from 'firebase/firestore';
import { db } from '@/config/firebase';
import type { User } from 'firebase/auth';
export const useCreateFireUser = (user: User | null) => {
const createFireUserMutaion = useFirestoreDocumentMutation(
doc(collection(db, 'users'), user ? user?.uid : '_')
);
const mutateDTO = () => {
if (user) {
console.log(user);
const newUser = {
displayName: user.displayName,
email: user.email,
role: 'USER',
};
createFireUserMutaion.mutate(newUser);
}
};
return {
...createFireUserMutaion,
mutateDTO,
};
};
フォームで入力して個別の関数でデータを受け取る場合
type CreatePostDTO = {
data: {
body: string;
};
};
// ...
const mutateDTO = (config: CreatePostDTO) => {
データ削除
import { useFirestoreDocumentDeletion } from '@react-query-firebase/firestore';
import { doc } from 'firebase/firestore';
import { db } from '@/config/firebase';
import type { Post } from '../types';
export const useDeletePost = (post: Post) => {
const deletePostMutaion = useFirestoreDocumentDeletion(
doc(db, 'users', post.author.path.split('/')[1], 'posts', post.id)
);
return deletePostMutaion;
};
docの参照は上記のようにも出来る。
collectionをimportしなくていいしこれで良いかもしれない。
複合処理
export const useCreatePostTBatch = () => {
const { user } = useFireAuth();
const userRef = doc(collection(db, 'users'), user ? user?.uid : '_');
const batch = writeBatch(db);
const createPostBatch = useFirestoreWriteBatch(batch);
const mutateDTO = (config: CreatePostDTO) => {
const newPost = {
body: config.data.body,
author: userRef,
createdAt: serverTimestamp(),
};
batch.set(doc(collection(userRef, 'posts')), newPost);
batch.set(doc(collection(db, 'posts')), newPost);
createPostBatch.mutate();
};
return {
...createPostBatch,
mutateDTO,
};
};
この例ではuserドキュメント配下とルートのpostsに対して同じデータが入る。
どちらかだけが成功するということは無い。
Security Rules
型安全の保障とアクセス権限を制御する。
以下に汎用的なバリデーションの一部と使用例を示す。
実際に運用をすると1つのモデルに対してCRUDそれぞれに制御が必要になると思われる。
これらのチェックにはエミュが役立つので活用する。ファイルの更新も即座に反映される。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// authenticated
function isAuthenticated() {
return request.auth != null;
}
// request user is same as resource user
function isUserAuthenticated(userId) {
return request.auth.uid == userId;
}
// request user is same as resource author
// function isMatchUserReference(ref, userId) {
// return ref != null && ref == get(/databases/$(database)/documents/users/$(userId)).__name__ && ref.size() > 0;
// }
// type check /////////////////////////////////////////////////////////////////////////
// email
function isValidEmail(email) {
return email.matches('^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$');
}
// string
function isValidString(string) {
return string is string && string.size() > 0;
}
// number
function isValidNumber(number) {
return number is number && number.size() > 0;
}
// boolean
function isValidBoolean(boolean) {
return boolean is bool && boolean.size() > 0;
}
// timestamp
function isValidTimestamp(timestampVal) {
return timestampVal is timestamp && timestampVal.size() > 0;
}
// array
function isValidArray(array) {
return array is list;
}
// value in array
function isValidInArray(array, value) {
return array is list && array.size() > 0 && value in array;
}
// object
function isValidObject(object) {
return object is map && object.size() > 0;
}
// path
function isValidPath(path) {
return path is path && path.size() > 0;
}
/////////////////////////////////////////////////////////////////////////
// validate user
function isValidUser(user) {
return
isValidString(user.displayName) &&
isValidEmail(user.email) &&
isValidInArray(['ADMIN', 'USER'], user.role);
}
// validate post
function isValidPost(post) {
return
isValidString(post.body) &&
isValidTimestamp(post.createdAt);
}
// user
match /users/{userId} {
allow read;
allow create: if isValidUser(request.resource.data) && isAuthenticated();
allow update: if isValidUser(request.resource.data) && isUserAuthenticated(userId);
}
// user's post
match /users/{userId}/posts/{postId} {
allow read;
allow create: if isAuthenticated();
allow update: if isValidPost(request.resource.data) && isUserAuthenticated(userId);
allow delete: if isUserAuthenticated(userId);
}
// post group
match /{path=**}/posts/{postId} {
allow read;
}
}
}
以上。
functionsやhostingはまだ利用していないが、データ構造を考えるのに悩まされたくないのでもうRDBでいいのではないかと思ってしまっている。