FirebaseはGoogleが提供する開発プラットフォームです。この記事は、Firebaseを利用した会員制サイトの作り方を紹介します。
題材は適当ですが、会員登録をしてログインすると、書籍ページで本の一覧を見られるサイトを作ります。
Firebaseには様々なサービスがありますが、認証機能はFirebase Authentication、データベース機能はFirebase Realtime Databaseを使います。
- 間違っている可能性もあるので自己責任で参考にしてください。
- 量が多すぎるので、全てのコードは掲載していません。
- ReactやTypeScriptの基本的な部分などは説明を省略しています。
Firebaseのサービス
Firebaseには、システム開発に役立つ様々なサービスが提供されています。今回はAuthenticationとRealtime Databaseを使うので、特徴を簡単に紹介します。
Authenticationの特徴
AuthenticationはFirebaseが提供する認証システムです。
会員登録、ログイン、ログアウトといった会員制サイトに必要な機能が最初から用意されており、画面で入力されたメールアドレスやパスワードなどをFirebaseのメソッドに渡すだけで実装ができます。
一般的なメールアドレスとパスワードによる認証の他に、Google、Facebook、Twitterなどの外部サービスを利用した認証もできることが特徴です。
Realtime Databaseの特徴
Realtime DatabaseはFirebaseが提供するNoSQLのデータベースです。Realtime Databaseを使えば、データベースを自前で用意しなくてもデータを保存できます。
主な特徴は、データをJSON形式で保存することです。そのため、複雑なデータを保存するには向きませんが、SQLを書かなくてもデータの取得や保存が可能です。
また、サービス名の通り、リアルタイムでサーバと端末が同期されます。データベースを更新すると、その内容がクライアント端末に即座に反映されます。
例えば、LINEのようなチャットアプリを作れます。LINEは画面をリロードしなくても新しいチャットが表示されたり、既読マークがついたりします。似たようなことがRealtime Databaseで実現できます。
事前準備
Firebaseを使用するには設定が必要です。この記事は、以下の記事の続きになります。(エミュレータの使用は必須ではありません)
Firebase Authenticationによる認証機能
ここからは、Firebase Authenticationを使って会員制サイトを作っていきます。
認証用コンテキストの作成
会員登録メソッドやログインメソッド、ログインしたユーザーの情報など、認証に関する情報に複数のコンポーネントからアクセスできるように、認証用のコンテキストを作成します。
import { createContext, ReactNode, useCallback, useContext, useLayoutEffect, useState } from "react";
import { createUserWithEmailAndPassword, signInWithEmailAndPassword, onAuthStateChanged, UserCredential } from "firebase/auth";
import { auth } from "../../firebase";
type AuthContextType = {
loginUserId: string | null;
authLoading: boolean;
signup: (email: string, password: string) => Promise<UserCredential>;
login: (email: string, password: string) => Promise<UserCredential>;
};
const AuthContext = createContext<AuthContextType>({} as AuthContextType);
export const useAuth = () => {
return useContext(AuthContext);
};
export const AuthProvider = (props: { children: ReactNode }) => {
const [loginUserId, setLoginUserId] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const signup = useCallback((email: string, password: string) => {
return createUserWithEmailAndPassword(auth, email, password);
}, []);
const login = useCallback((email: string, password: string) => {
return signInWithEmailAndPassword(auth, email, password);
}, []);
useLayoutEffect(() => {
onAuthStateChanged(auth, (user) => {
setAuthLoading(true);
if (user) {
setLoginUserId(user.uid);
}
setAuthLoading(false);
});
}, []);
return (
<AuthContext.Provider
value={{
loginUserId,
authLoading,
signup,
login,
}}
>
{!authLoading && props.children}
</AuthContext.Provider>
);
};
signupは会員登録のメソッドで、createUserWithEmailAndPasswordというFirebaseのメソッドを実行します。loginはログインのメソッドで、FirebaseのsignInWithEmailAndPasswordメソッドを実行します。
onAuthStateChangedは「オブザーバー」と呼ばれるもので、ログインまたはログアウトしたときに実行されます。上記のサンプルはログアウト時の処理を省略していますが、ログイン時はユーザーの情報をstateに保存したり、ログアウト時はstateから削除するといった使い方をします。
分かりやすいようにユーザーのIDのみをstateに保存していますが、氏名やメールアドレスなどのログインしたユーザー情報を保存することも可能です。
ちなみに、オブザーバーを使わなくても、ログイン時にはユーザー情報がcurrentUserプロパティに設定されます。
const user = auth.currentUser;
しかし、タイミングによってはnullになることもあるようで、オブザーバーが推奨されています。
プライベートルートの作成
ログインする前は書籍ページを表示できないように、プライベートルートを作成します。
import { memo, VFC } from "react";
import { Route, RouteProps, Redirect } from "react-router-dom";
import { useAuth } from "./AuthContext";
export const PrivateRoute: VFC<RouteProps> = memo(
({ component: RouteComponent, ...options }) => {
const { loginUserId } = useAuth();
return loginUserId ? (
<Route {...options} component={RouteComponent} />
) : (
<Redirect to="/login" />
);
}
);
先ほど作成した認証用コンテキストからログインしたユーザーのIDを取得して、取得できた場合は本来のコンポーネントにルーティングを行い、取得できなかった場合はログインページにリダイレクトします。
PrivateRouteは以下の記事を参考にさせていただきました。
プライベートルートでルーティングを行います。
import { memo, VFC } from "react";
import { Route, Switch } from "react-router-dom";
import { AuthProvider } from "../components/auth/AuthContext";
import { PrivateRoute } from "../components/auth/PrivateRoute";
import { Home } from "../components/page/Home";
import { Login } from "../components/page/Login";
import { SignUp } from "../components/page/SignUp";
import { Result } from "../components/page/Result";
import { Book } from "../components/page/Book";
export const Router: VFC = memo(() => {
return (
<AuthProvider>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/login" component={Login} />
<Route exact path="/signup" component={SignUp} />
<Route exact path="/result" component={Result} />
<PrivateRoute exact path="/book" component={Book} />
</Switch>
</AuthProvider>
);
});
書籍ページを会員限定にするため、プライベートルートを使います。 また、各ページのコンポーネントから認証用コンテキストにアクセスできるように、AuthProviderで囲います。
会員登録
認証用コンテキストのsignupメソッドを使って会員登録機能を作ります。
const onClickSignUp = () => {
signup(email, password)
.then(() => history.push("/result"))
.catch((error) => {
switch (error.code) {
case "auth/email-already-in-use":
setErrorMessage(
"メールアドレスが既に使用されています。別のメールアドレスを入力してください。"
);
break;
case "auth/invalid-email":
setErrorMessage("メールアドレスの形式が不正です。");
break;
case "auth/weak-password":
setErrorMessage("パスワードは6文字以上で入力してください。");
break;
default:
setErrorMessage(
"会員の登録が失敗しました。再度お試しいただくか、お問い合わせフォームよりご連絡ください。"
);
}
});
};
会員登録が成功した場合、history.pushで処理結果ページを表示します。
会員登録が失敗すると、Firebaseからは例外が投げられます。そして、エラーコードに応じてエラーメッセージをstateに保存します。(エラーコードは公式サイトに記載されているものです)
ログイン
認証用コンテキストのloginメソッドを使ってログイン機能を作ります。
const onClickLogin = () => {
login(email, password)
.then(() => history.push("/book"))
.catch(() => {
setErrorMessage(
"ログインに失敗しました。メールアドレスまたはパスワードが違います。"
);
});
};
ログインが成功した場合、history.pushで書籍ページを表示します。
ログインが失敗すると、Firebaseからは例外が投げられます。会員登録と同様にauth/invalid-emailやauth/user-not-foundなどエラーコードに応じた分岐は可能ですが、ユーザーにとってはあまり意味がなく、セキュリティ的にも良くないことから、一つのエラーメッセージに集約しています。
Firebase Realtime Databaseによるデータの取得機能
これまでの手順で会員登録およびログイン機能を作成して、書籍ページはログインしないと閲覧できないようになりました。
ここからは、書籍ページに書籍情報の登録と表示機能を作成します。
データの登録
以下は、書籍情報を入力して登録ボタンをクリックしたときの処理です。
import { db } from "../../firebase";
import { ref, set, push, child, onValue } from "firebase/database";
const onClickRegister = () => {
const key = push(child(ref(db), "books")).key;
set(ref(db, "books/" + key), {
title: title,
author: author,
});
};
Firebaseのpushメソッドで、登録する書籍情報のキーを採番します。自動採番せずに、任意の値をキーにすることも可能です。
そして、setメソッドでデータの保存を行います。setメソッドの第一引数には、refメソッドを使ってデータの保存先を指定します。今回は「books/自動採番したキー」がパスになっています。
第二引数には保存する情報を指定します。書籍のタイトルと著者名のオブジェクトを保存しています。
Realtime DatabaseはJSON形式でデータを保存します。上記のサンプルは、以下のようなイメージです。
{
"books": {
"1": {
"author": "田中太郎",
"title": "動物図鑑"
},
"2": {
"author": "高橋次郎",
"title": "海の生物"
}
}
}
キーとなる「1」や「2」の部分は、自動採番した場合はランダムな文字列になります。
JSONのファイルにデータを用意しておけば、インポート機能でRealtime Databaseへの一括登録も可能です。
データの表示
次に、登録した書籍情報の一覧を画面に表示します。
const [bookList, setBookList] = useState<BookList>({});
useEffect(() => {
const reference = ref(db, "books/");
onValue(reference, (snapshot) => {
const data = snapshot.val();
if (data != null) {
setBookList(data);
}
});
}, []);
refメソッドで取得したい情報のパスを指定して、それをonValueメソッドに渡します。
onValueメソッドはスナップショットというものを返すので、valメソッドを使ってJavaScriptのオブジェクトに変換します。そして、データが取得できた場合はstateに保存します。
書籍情報一覧のTypeScriptの型も参考として紹介します。
type BookList = {
[uid: string]: {
title: string;
author: string;
};
};
onValueメソッドはオブザーバーと呼ばれ、データの変更を監視しています。サーバ上のデータが変更されると、画面をリロードしなくても新しい情報で画面を更新します。
データの監視が不要で、一度だけデータを表示すれば良い場合、以下のようにonlyOnceというオプションにtrueを指定します。
onValue(reference, (snapshot) => {
const data = snapshot.val();
if (data != null) {
setBookList(snapshot.val());
}
},
{ onlyOnce: true });
最後に、取得した書籍情報を表示するHTMLを参考に紹介します。
<table className="book-table">
<thead>
<tr>
<th>タイトル</th>
<th>著者</th>
</tr>
</thead>
<tbody>
{Object.keys(bookList).map((uid) => (
<tr key={uid}>
<td>{bookList[uid].title}</td>
<td>{bookList[uid].author}</td>
</tr>
))}
</tbody>
</table>