はじめに
TypeScript × React × Next.js × Supabaseを使用して個人開発を行っているのですが、
ログイン状態の管理を行う際にuseContextを使用しました。
流れを理解していないとスムーズに使いこなせなさそうだったので、備忘録としてまとめてみます。
あくまで分かりやすくまとめているので、詳細まで知りたい方は下記公式リファレンスも参照してみて下さい。
公式リファレンス-useContextについて
また、今回はSupabaseを使用しているので、認証状態の管理とクリーンアップ処理についても軽く触れてみます。
※誤りありましたら、ご指摘いただけますと幸いです。
(補足)Supabaseについて
Supabaseは、Firebaseのオープンソース代替として知られる、Baas(Backend-as-a-Service)です。
開発者が素早くアプリケーションを構築できるように、データベース・認証・API 等をまとめて提供してくれます。
データベースはPostgreSQLをベースにしており、フロントエンドから直接DBにアクセス可能です。
ログイン認証やストレージ機能も提供されており、まさしくバックエンド全般に関わる部分をコンパクトに実装することができます。
今回は、フロントエンド周りのキャッチアップということで、バックエンド・DB周りはSupabaseに任せてみたいと思います。無料で使用できますので、興味のある方は実際に使用してみていただければと思います。
useContextとは
それでは実際に紐解いていきます。
useContextは、一言で言うと「グローバルな状態を扱う仕組み」です。
今回のテーマとして扱うログイン状態のように、Reactの各コンポーネントで共通でアクセスしたい情報を、何階層にもわたり毎回propsで流していてはコードが肥大化してしまいます。
そこで登場するのが今回のuseContextになります。
実際の使い方
文字で説明しても伝わりづらいと思うので、実際にコードに起こして確認してみましょう。
今回のテーマの通り、ユーザープロフィールをコンテキスト化する、という前提で書いていきます。
後から各ステップに分けて解説していきますので、まずはUserContext.tsxの全体像を見てみましょう。
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { supabase } from '@/lib/supabase';
type UserProfile = {
id: string;
nickname: string;
} | null;
const UserContext = createContext<UserProfile>(null);
export const useUser = () => useContext(UserContext);
export const UserProvider = ({ children }: { children: ReactNode }) => {
const [userProfile, setUserProfile] = useState<UserProfile>(null);
const fetchUser = async () => {
const { data: authData, error: authError } = await supabase.auth.getUser();
if (authError || !authData.user) {
setUserProfile(null);
return;
}
const { data: profileData, error: profileError } = await supabase
.from('profiles')
.select('id, nickname')
.eq('user_id', authData.user.id)
.single();
if (profileData && !profileError) {
setUserProfile(profileData);
} else {
setUserProfile(null);
}
};
useEffect(() => {
fetchUser(); // 初回取得
// ログイン・ログアウト・再ログイン時に自動で再取得(イベントリスナーに似た形)
const { data: subscription } = supabase.auth.onAuthStateChange(() => {
fetchUser(); // ログイン・ログアウト時に再取得
});
return () => {
subscription.subscription.unsubscribe(); // クリーンアップ
};
}, []);
return <UserContext.Provider value={userProfile}>{children}</UserContext.Provider>;
};
では、上記を各ステップに分けて解説して行きます。
ステップ①:型定義の用意(userProfile)
type UserProfile = {
id: string;
nickname: string;
} | null;
型定義では、nullも含めることで未ログイン状態も表現可能としています。
ステップ②: Context の作成
const UserContext = createContext<UserProfile>(null);
createContextを使用して、Contextを作成しています。
ジェネリクスでステップ①で作成したUserProfile型を使用することを明示し、初期値は未ログイン状態を表すnullとしています。
ステップ③:カスタムフック useUser を定義
export const useUser = () => useContext(UserContext);
カスタムフック(useから始まる関数で、複数のコンポーネントで再利用したいロジックをまとめた関数)を作成することで、
コンポーネント側ではuseUser()を呼び出すだけで、ユーザーのプロフィール情報を取得できるようになります。
ステップ④:UserProvider コンポーネントを作成
export const UserProvider = ({ children }: { children: ReactNode }) => {
const [userProfile, setUserProfile] = useState<UserProfile>(null);
const fetchUser = async () => {
const { data: authData, error: authError } = await supabase.auth.getUser();
if (authError || !authData.user) {
setUserProfile(null);
return;
}
const { data: profileData, error: profileError } = await supabase
.from('profiles')
.select('id, nickname')
.eq('user_id', authData.user.id)
.single();
if (profileData && !profileError) {
setUserProfile(profileData);
} else {
setUserProfile(null);
}
};
useEffect(() => {
fetchUser(); // 初回取得
// ログイン・ログアウト・再ログイン時に自動で再取得(イベントリスナーに似た形)
const { data: subscription } = supabase.auth.onAuthStateChange(() => {
fetchUser(); // ログイン・ログアウト時に再取得
});
return () => {
subscription.subscription.unsubscribe(); // クリーンアップ
};
}, []);
return <UserContext.Provider value={userProfile}>{children}</UserContext.Provider>;
};
ここで UserProvider なる関数が登場しています。
この役割ですが、ものすごく簡単に説明すると、今回で言うところの、ユーザー情報の提供 を担っています。
逆にuseContext(UserContext)でUserProviderが提供した情報を使用できるわけです。
具体的な処理についても順を追って説明します。
- Supabaseでログイン中ユーザーの情報を取得(supabase.auth.getUser())
- profilesテーブルから追加情報(nicknameなど)の取得
- useStateでログイン状態を保持
- useEffect + onAuthStateChange でログイン・ログアウトを監視
- でグローバルにユーザー情報を提供
2ですが、Supabaseでは、ユーザー情報を管理する独自のAuthentication が存在します。
そこでは、登録されたユーザーのメールアドレスや、各ユーザーごとに発行されるUUID等々が管理されます。
しかし、デフォルトで管理されているユーザー情報の他に、ニックネームのような独自で管理したいユーザー情報がある場合は、profilesテーブル等を用意して独自で管理する必要があります。
追加情報を取得と表現しているのはこのためになります。
※ onAuthStateChange とクリーンアップ処理については後ほど説明します。
ステップ⑤:アプリのルートで UserProvider を使う
// _app.tsx or layout.tsx
import { UserProvider } from '@/contexts/UserContext';
function App({ Component, pageProps }: AppProps) {
return (
<UserProvider>
<Component {...pageProps} />
</UserProvider>
);
}
ルートファイルである app.tsx にUserProviderを適用します。
Next.jsにおいて、全てのページは最終的にこのAppコンポーネントの Component として呼び出されるため、
<Component {...pageProps} />
この部分は「現在表示されているページのコンポーネント」を意味しています。
これを UserProvider でラップすることで、どのコンポーネントからもuseUser()を使ってユーザー情報にアクセスできるようになります。
ステップ⑥:任意のコンポーネントでユーザー情報を利用
import { useUser } from '@/contexts/UserContext';
export default function Header() {
const user = useUser();
return (
<header>
{user ? (
<p>ようこそ、{user.nickname}さん!</p>
) : (
<p>ログインしてください</p>
)}
</header>
);
}
ヘッダーコンポーネントで、ユーザーのニックネームを表示しています。
先述の通り、useUser() を呼び出すのみで、ユーザーのプロフィール情報が取得できていることが分かります。
クリーンアップ処理について
ステップ②で、UserProvider内で、onAuthStateChange() なる何かを使用していました。
onAuthStateChange()は、ログイン・ログアウト・セッション切れなどのタイミングで呼ばれるイベント であり、
これを使ってユーザー情報を取得することで、ログアウト後の旧ユーザー情報からの切り替えが容易になります。
しかし、onAuthStateChange() は定期購読イベントリスナーを登録しているので、そのまま放置すると、再レンダリングのたびに登録され続けてしまい、
バグやメモリリークの温床になってしまいます。
そのため、下記を呼び出すことで、解除(クリーンアップ)していた、というわけです。
return () => {
subscription.subscription.unsubscribe(); // クリーンアップ
};
最後に
今回は備忘録としてuseContextの仕組みをまとめてみました。
フレームワークとしてNext.jsを使用すると、Springとは大きく異なった使用感があり、非常に面白いです。
一方でベースとなっているのはやはりTypeScript、そしてReactなわけですから、ここの基礎知識定着が非常に重要課題だと認識しています。
個人開発の目的はあくまでフロントエンドのキャッチアップを目的としてやっているので、まだまだ勉強したてな分、どんどん分からない部分が出てきますが、逃げずにかみ砕いていきたいと思います。