この記事について
先日作成したReact & Firebaseを使ったWebサービス開発入門の続編であり、まだ全編を読んでない方はそちらを先にお読みください。
このサービスについて
私も含めWebアプリ開発を行うことを志した人間が最も初めに作る本格的なサービスはおおよそSNSになるのではないだろうか。
それも当然でWebサービスに必要な機能が全て揃っている。データベースのCRUDとユーザログインである。
このサービスはユーザ毎に気になる映画をお気に入りに保存したり解除したりすることを肝としているため、これらの機能追加は避けられない。
それでは今回FirestoreとFirebase Authenticationを使って実装していく。
開発開始
Routerの設定
まずお気に入りページとサインイン・サインアウトページと今回のサービスにおいて必要となるページを作成するにあたってのルーティング設定を作っていく。
Reactでページ遷移を行うためには必須の要素だ。
import { Routes, Route } from "react-router-dom";
import App from "./App";
import Favorite from "./component/Favorite";
import SignUp from "./component/SignUp";
import SignIn from "./component/SignIn";
export const Routing = () => {
return (
<Routes>
<Route path="/" element={<App />} />
<Route path="/favorite" element={<Favorite />} />
<Route path="/sign-up" element={<SignUp />} />
<Route path="/sign-in" element={<SignIn />} />
{/* 指定URLが存在しない場合ここに飛ぶ */}
<Route path="/*" element={<App />} />
</Routes>
)
}
ルーティングファイルの根本はタブのようなものとイメージしてもらうとわかりやすい。
つまり、ページ遷移先の説明を書いた独立した設計書ではなく、この要素自体がコンポーネントなのである。
この認識を持てば、ルーティングの仕方が様々あれど概ねトラブルなく実装できるであろう。
import { HelmetProvider } from 'react-helmet-async';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
+ import { Routing } from "./routing";
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<HelmetProvider>
+ <BrowserRouter>
+ <Routing />
+ </BrowserRouter>
</HelmetProvider>
</React.StrictMode>
);
これはindex.jsであるが、Routingをコアコンポーネントとして設置する。
Firestoreの作成
次にデータベースであるFirestoreを作成する。
FirestoreはNoSQLの代表格で事前にカラムを設計するテーブル型のデータベースとは違い、データベース内にコレクション(SQLでいうデータテーブル)というデータの箱を作りJavaScriptのDictionaryのような形でデータを格納する。
作成手順は以下の通りだ。
Farebaseの設定記述
Firestoreを立ち上げただけではもちろんアプリ内では使えないので、API接続をして先程作ったFireStoreをコード内で呼び出せるようにする。
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: "Your Info",
authDomain: "Your Info",
projectId: "Your Info",
storageBucket: "Your Info",
messagingSenderId: "Your Info",
appId: "Your Info",
measurementId: "Your Info"
};
// Firebaseをインスタンス化
const app = initializeApp(firebaseConfig);
// Cloud Firestoreをインスタンス化
export const db = getFirestore();
// Firebase Authenticationをインスタンス化
export const auth = getAuth(app);
export default app;
これらのキーは自分のFirebaseの情報を見ながら適宜変えていけば良い。
そして、くれぐれもGithubに上げないように気をつけよう。
Favoriteの実装
お気に入りとして登録された映画情報を取り出して表示するページを実装していく。お気に入りとしてFirestoreに登録するところとログインユーザーを取り出すところは後述する。
import '../App.css';
import { useState, useEffect, useRef } from 'react';
import api from '../util/movieApi';
import requests from '../util/requests';
import { Link } from "react-router-dom";
import { Helmet } from 'react-helmet-async';
import { collection, query, where, getDocs } from 'firebase/firestore';
import { db } from '../util/firebase';
import { useAuthContext } from '../context/AuthContext';
const posterBaseUrl = 'https://image.tmdb.org/t/p/original';
const Favorite = () => {
const favoriteIds = [];
const [movies, setMovies] = useState([]);
const { currentUser } = useAuthContext();
useEffect(() => {
const q = query(collection(db, "favorite_movies"), where("user_id", "==", currentUser.uid));
const fetchData = async (fetchUrl) => {
const request = await api.get(fetchUrl);
return request.data;
};
getDocs(q)
.then((querySnapshot) => {
querySnapshot.docs.map((doc) => {
// React hooksのset関数を使う場合、set関数はmapなどによる連続的な呼び出しに耐えられないためfavoriteIds(Array)を使用
favoriteIds.push(doc.data().movie_id);
});
// お気に入り映画のIDを取り出し、全てAPIのエンドポイントに変えて配列に格納(Setで重複も消す)
const fetchUrls = Array.from(new Set(favoriteIds.map((favoriteId) => requests.fetchById.replace('movieId', favoriteId))));
const fetchFunc = fetchUrls.map((fetchUrl) => fetchData(fetchUrl));
// set関数は連続的な呼び出しに弱いため、fetchDataの中で呼び出すのではなく、Promise.allで一括実行したのち、その結果を一度のset呼び出しで格納する
Promise.all(fetchFunc).then((res) => {
setMovies(res);
});
});
}, []);
return (
<div className="favorite">
<Helmet>
<title>Favorite List</title>
</Helmet>
<Link className="link" to="/">ホームに戻る</Link>
<h1 className="favorite_description">あなたのお気に入りの映画をここに保存しています</h1>
<div className="favorite_contents">
{movies.map((movie) =>(
<div className="favorite_content">
<img
key={movie.id}
className="favorite_poster"
src={posterBaseUrl+movie.poster_path}
alt={movie.title}
/>
<h4>{movie.title}</h4>
<p>{movie.overview}</p>
</div>
))}
</div>
</div>
);
}
export default Favorite;
useStateは大変便利な機能であるが、あまり述べられていない弱点がある。それは連続呼び出しに弱いということだ。
なので、querySnapshot.docs.mapの部分なんかでuseStateを使いたい場合
setHoge(querySnapshot.docs.map((doc) => doc.data().movie_id;}));
というように設定しset関数の呼び出しを一回にまとめる必要がある。
そして、Firestoreへの保存データの呼び出しはqueryの作成後、「getDocs」でクエリスナップチャット(その中にドキュメントスナップチャットが格納されている)を作成し、データを取り出す。
今回のコードでは、お気に入りに登録した「movie_id」を取り出しAPIエンドポイントに変えたのち、全てまとめて「Promise.all」でAPIにリクエストをかける。
帰ってきたデータをsetMoviesに格納しデータ取得は完了。
あとは他のページと同様にただ表示すれば良い。
Frontでお気に入り登録を行う
import '../App.css';
import { useState, useEffect, useRef } from 'react';
+ import { collection, doc, query, where, getDocs, addDoc, deleteDoc } from 'firebase/firestore';
+ import { db } from '../util/firebase';
import api from '../util/movieApi';
import requests from '../util/requests';
import YouTube from 'react-youtube';
import movieTrailer from 'movie-trailer';
const Front = (props) => {
const moviesCollectionRef = collection(db, 'favorite_movies');
const [movie, setMovie] = useState();
const [trailerUrl, setTrailerUrl] = useState('');
+ const watchRef = useRef(null);
+ const favAddRef = useRef(null);
+ const favRemRef = useRef(null);
useEffect(() => {
const fetchData = async () => {
const request = await api.get(requests.fetchTopRated);
const frontMovie = request.data.results[Math.floor(Math.random() * request.data.results.length - 1)];
setMovie(frontMovie);
return frontMovie;
};
const existFav = async (movie) => {
if (movie) {
try {
// Firestoreにおけるクエリ検索の仕方
const q = query(collection(db, "favorite_movies"), where("movie_id", "==", movie.id), where('user_id', '==', props.uid));
const querySnapshot = await getDocs(q);
// querySnapshot.exist()やquerySnapshot.docs.exist()は使えない
if (querySnapshot.docs.length && favRemRef.current && favAddRef.current) {
// 既にお気に入りに登録されている場合
favRemRef.current.style.display = "inline-block";
favAddRef.current.style.display = "none";
} else if (favRemRef.current && favAddRef.current) {
favRemRef.current.style.display = "none";
favAddRef.current.style.display = "inline-block";
}
} catch (error) {
console.log(error);
}
}
}
fetchData().then(function(data) {
// Fetchした映画が既にお気に入りに登録されているかチェック
existFav(data);
});
}, []);
const addFav = async (movieId) => {
try{
// addDocは自動生成されたDocIdを返し、setDocは任意のDocIdを指定できる
const documentRef = await addDoc(moviesCollectionRef, {
// movie_idはqueryを行うため、stringやnumberの型に注目
movie_id: movieId,
user_id: props.uid,
});
alert("お気に入りに追加しました")
favRemRef.current.style.display = "inline-block";
favAddRef.current.style.display = "none";
} catch (e) {
alert("お気に入り追加に失敗しました")
}
}
const remFav = async (movieId) => {
try{
const q = query(moviesCollectionRef, where('movie_id', '==', movieId), where('user_id', '==', props.uid));
const querySnapshot = await getDocs(q);
querySnapshot.forEach(async (document) => {
const favDocRef = doc(db, 'favorite_movies', document.id);
await deleteDoc(favDocRef);
});
alert("お気に入りから削除しました")
favRemRef.current.style.display = "none";
favAddRef.current.style.display = "inline-block";
} catch (e) {
alert("お気に入り削除に失敗しました")
}
}
return (
<div className="head_contents">
<h1>{movie?.name || movie?.title}</h1>
<div className="head_buttons">
<button
className="head_button"
onClick={() => handleView(movie)}
ref={watchRef}>
視聴
</button>
<button
className="head_button add"
onClick={() => addFav(movie?.id)}
ref={favAddRef}>お気に入り登録</button>
<button
className="head_button rem"
onClick={() => remFav(movie?.id)}
ref={favRemRef}>お気に入り解除</button>
</div>
<p className="head_description">{movie?.overview}</p>
</div>
</header>
);
}
export default Front;
色々な処理を行っているが、簡単にまとめると
- 非同期処理のfetchData()でthenをとり、データ取得後existFav()が走るようになっている
- existFav()ではqueryを作り「ログインユーザーがその映画を既にお気に入りデータベースの登録しているか」をチェック、存在した場合「querySnapshot.docs.length」がtrueとなる
- 既にお気に入りだと表示では「お気に入り解除ボタン」を、まだお気に入りでないと「お気に入り登録ボタン」を設置するため、useRefを使いボタンの表示を切り替える
- お気に入り登録ボタンにはFirestoreへの追加機能であるaddDocを実装した「addFav」をお気に入り登録ボタンにはFirestoreからの削除機能であるdeleteDocを実装した「remFav」をイベント関数として設置
- その際asyncとして非同期関数にして、awaitで処理に時間差不具合が起こらないようにコントロールを行う
Reactによるデータベース利用において非同期処理の取り扱いが最も不具合が生じやすく、しっかり流れを理解しなければならない領域である。
サインインとサインアップ機能の実装
データベース機能の説明が終わったところでユーザー機能の話に移っていくが、
Front.js内でもuser_idを使ってユーザー毎にお気に入りの映画を取ってきているように、ユーザー機能はWebサービスにおいて絶対必須である。
const q = query(collection(db, "favorite_movies"), where("movie_id", "==", movie.id), where('user_id', '==', props.uid))
これもFirebaseのおかげで比較的すぐ実装できる。
Firebase Authenticationの利用は以下の手順で
まずFirebaseメインページからAuthenticationを選択
以上である。コード内での使用は既にfirebase.jsでも書いてある通り
export const auth = getAuth(app);
ユーザーを作成するサインアップページの実装であるが、
import '../App.css';
import { auth } from '../util/firebase';
import { createUserWithEmailAndPassword } from "firebase/auth";
import AuthStateChecker from '../component/AuthStateChecker';
import { useState } from 'react';
import { Helmet } from 'react-helmet-async';
import { Link, useNavigate } from "react-router-dom";
const SignUp = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const navigate = useNavigate();
const handleSingUp = () => {
if (email && password) {
createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// 登録したユーザーを返す
const user = userCredential.user;
alert("ユーザー登録完了しました");
navigate("/");
})
.catch((error) => {
alert("ユーザー登録に失敗しました\n"+error.message);
});
}
}
return (
<div className="sign_up">
<Helmet>
<title>Sign Up</title>
</Helmet>
<h1 className="sign_up_description">ユーザー登録してMovie Guideにアクセス</h1>
<div className="sign_up_form">
<label>メールアドレス</label>
<input
name="email"
type="email"
placeholder="hoge@gmail.com"
onChange={(event) => setEmail(event.target.value)}
/>
</div>
<div className="sign_up_form">
<label>パスワード</label>
<input
name="password"
type="password"
placeholder="******"
onChange={(event) => setPassword(event.target.value)}
/>
</div>
<div className="sign_up_form">
<button
className="sign_up_button"
onClick={() => handleSingUp()}
>登録</button>
</div>
<Link className="sign_up_link" to="/sign-in">アカウントをお持ちの方はこちらから</Link>
</div>
);
};
export default SignUp;
肝となる部分は
createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// 登録したユーザーを返す
const user = userCredential.user;
alert("ユーザー登録完了しました");
navigate("/");
})
初めてこの登録機能を見たときはたまげたものである。めっちゃ簡単!
ちなみに自動でTop画面に行くわけではないので、navigate("/")によるリダイレクトを忘れずに
次にログイン機能を実装しているサインインページであるが
import '../App.css';
import { auth } from '../util/firebase';
import { signInWithEmailAndPassword } from "firebase/auth";
import { useState } from 'react';
import { Helmet } from 'react-helmet-async';
import { Link, useNavigate } from "react-router-dom";
const SignIn = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// リダイレクトに使う
const navigate = useNavigate();
const handleSingIn = () => {
if (email && password) {
signInWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// ログインしたユーザーを返す
const user = userCredential.user;
navigate("/");
})
.catch((error) => {
alert("ログインに失敗しました\n"+error.message);
});
}
}
return (
<div className="sign_up">
<Helmet>
<title>Sign Up</title>
</Helmet>
<h1 className="sign_up_description">ログインしてMovie Guideにアクセス</h1>
<div className="sign_up_form">
<label>メールアドレス</label>
<input
name="email"
type="email"
placeholder="hoge@gmail.com"
onChange={(event) => setEmail(event.target.value)}
/>
</div>
<div className="sign_up_form">
<label>パスワード</label>
<input
name="password"
type="password"
placeholder="******"
onChange={(event) => setPassword(event.target.value)}
/>
</div>
<div className="sign_up_form">
<button
className="sign_up_button login"
onClick={() => handleSingIn()}
>ログイン</button>
</div>
<Link className="sign_up_link" to="/sign-up">アカウント作成はこちらから</Link>
</div>
);
};
export default SignIn;
ここもサインアップページと同じで肝は非常にシンプル
signInWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// ログインしたユーザーを返す
const user = userCredential.user;
navigate("/");
})
.catch((error) => {
alert("ログインに失敗しました\n"+error.message);
});
これはたまげた!
ログイン状態管理部分の実装
以上でユーザー登録の部分は作り終えたが、このままでは非ログインユーザーがTop画面に直接遷移したりするのを弾けないため、それを可能にするコンポーネントを実装していくが、
まず現在ログインしているユーザー情報を取得するためにuseContextを使った処理を作っていく。
import { createContext, useState, useContext, useEffect } from 'react';
import { auth } from '../util/firebase';
import { onAuthStateChanged } from 'firebase/auth';
import Loading from '../component/Loading';
const AuthContext = createContext();
export function useAuthContext() {
return useContext(AuthContext);
}
export function AuthProvider({ children }) {
const [currentUser, setCurrentUser] = useState('');
const [signInCheck, setSignInCheck] = useState(false);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
if (user) {
setCurrentUser(user);
setSignInCheck(true);
} else {
setCurrentUser('');
setSignInCheck(true);
}
});
// コンポーネントのアンマウント時にunsubscribeでonAuthStateChangedに登録していた関数を解除しないと多重登録になる
return () => {
unsubscribe();
};
}, []);
// これがないとリロードした時に非同期のonAuthStateChangedにより一瞬ログインユーザー不在となり、サインインページに引き戻される
if (signInCheck) {
return <AuthContext.Provider value={{currentUser}}>{children}</AuthContext.Provider>;
} else {
// ログイン情報読み込み中
return <Loading />;
}
}
useEffectでレンダリング時に非同期処理の「auth.onAuthStateChanged」を走らせて現在のユーザーを取得する。
また非同期処理が厄介者になってくるが、これの解決はコード内にも書いているが、signInCheckという変数がミソなのである。
まー、これは実際signInCheckアリナシで比べてみたら良い。
そして、それと同様に重要なのが、setCurrentUser('')であり、これがないとログアウトが適切に機能しない。
これによって
const { currentUser } = useAuthContext()
で簡単に現在のログインユーザーの情報が取れるようになる。
useContextを学習した人はわかると思うが、AuthContext.Providerで適用箇所を囲むことが必要。
+ import { AuthProvider } from './context/AuthContext';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<HelmetProvider>
+ <AuthProvider>
<BrowserRouter>
<Routing />
</BrowserRouter>
+ </AuthProvider>
</HelmetProvider>
</React.StrictMode>
);
それでは次にAuthContextを使って非ログインユーザーを弾く機能を実装していく。
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthContext } from '../context/AuthContext';
const AuthStateChecker = (props) => {
const navigate = useNavigate();
// (注)AuthContext.Providerにおいて「value=>currentUser」として登録したため、一字でも異なると作動しない
const { currentUser } = useAuthContext();
useEffect(() => {
if (!currentUser && !props.isOutside) {
navigate("/sign-in");
} else if (currentUser && props.isOutside) {
navigate("/");
}
}, []);
return <div>{props.children}</div>;
};
export default AuthStateChecker;
useAuthContext()でユーザー情報を取得し、非ログインユーザーのTopページやFavoriteページのアクセスにはnavigate("/sign-in")で対処し、ログインユーザーのサインイン・サインアップページのアクセスにはnavigate("/")で対処する。
そして、この機能を実際使用する方法は非常に簡単で適用したい範囲を要素で囲むだけ
例えば、App.jsの例を見てみる
return (
+ <AuthStateChecker>
<div className="app">
<Helmet>
<title>Movie Guide</title>
</Helmet>
<Front uid={currentUser.uid} />
<h1 className="app_description">Movie Guideへようこそ、あなたの次に見てみたい映画を探して行ってくださいな</h1>
<Row title='Netflixオリジナル' fetchUrl={requests.fetchNetflixOriginals}/>
<Row title='今話題' fetchUrl={requests.fetchTrending} />
<Row title='大人気' fetchUrl={requests.fetchTopRated} />
<Row title='アクション映画' fetchUrl={requests.fetchActionMovies} />
<Row title='コメディ映画' fetchUrl={requests.fetchComedyMovies} />
+ <button className="link logout" onClick={() => logOut()}>ログアウト</button>
<Link className="link toList" to="/favorite">お気に入りリスト</Link>
</div>
+ </AuthStateChecker>
);
}
export default App;
では、最後にサインアウトの実装であるが、
const logOut = () => {
const result = window.confirm('あなたは今「'+currentUser.email+'」のメアドでログインしています。ログアウトしますか?');
if( result ) {
signOut(auth).then(() => {
navigate('/sign-in');
}).catch((error) => {
console.log("ログアウト失敗");
});
}
else {
return null;
}
}
これもsignOut関数一発で実行できるのだから、Firebaseおそるべしである。
総括
以上がReactとFirebaseを使ったWebサイト開発のいろはであるが、ここから入ってユーザーの権限管理やファイル処理など徐々に機能を拡大していけば本格的なサービス開発も可能である。
これは自戒も兼ねるが、是非これからも精進していってもらいたい。