React + Rails API構成で作る認証状態の責務を考え、zustandを採用した話
はじめに
現在、私は未経験でWeb系エンジニア転職を目指しており、学習、個人ポートフォリオ開発を行っております。
RUNTEQ卒業制作を経て、ポートフォリオ拡充のため、React + Rails API構成で作るSPA開発にチャレンジした知見をもとに技術記事を書こうと思います。
この記事では、React + Rails API構成で認証機能を実装する中で、
「認証状態をどこで管理するべきか」を考え、zustandを採用した理由について整理します。
※ 本記事は、個人開発アプリ「ColorMirror_Re」の開発で得た学びをもとにしています。
注意
本記事は、実務未経験のポートフォリオ開発を経て、得た知見を基に作成しています。
拙ない表現や、認識の甘さが見つかった際はご容赦いただけると幸いです。🙇♀️
GitHubページ
アプリへのリンク
作っているアプリの構成
技術スタック
- Frontend: React / Vite / TypeScript
- Backend: Ruby on Rails APIモード
- 認証: Rails gem: devise_token_auth
- 状態管理: zustand
- API通信: axios / TanStack Query
全体構成
ユーザー認証の構成図
認証の状態について管理する理由
ユーザーにとっての認証状態とは?
以下の3点が挙げられる
- 認証済み
- 未認証
- 不明(通信中など)
もし、何もしなければ、アプリを触っているユーザーが上の3つの内どの状態にいるのかをフロント側(ブラウザ)は判定することができません。
フロントとサーバーが分割されている場合、フロントにいるユーザーが
- DB(サーバー)の中にいるどのユーザーなのか
- すでに認証済みのユーザーなのか
をフロント側だけでは覚えていられないからです。
そこで、フロント側のUI制御において、ユーザーの現在の状態を管理(認証の状態を記憶)しておく必要があります。
なぜなら、
- 認証済みのユーザーが触れるUI(アプリの機能画面)
- 未認証のユーザーが触れるUI(ログイン画面)
この2点をユーザーの状態によってUIを切り替えて制御する必要があるからです。
反対に、以下のような状態はUX的にもアプリとして自然な状態ではないと言えます。
未認証のユーザーが認証済みのページにアクセスできてしまう状態
UIの制御のために、フロント側で認証の状態を管理する必要性について述べてきましたが、方法としては2つあります。
- localにstate(状態)を持つ
- 単一コンポーネント内でスコープを持つ
- その状態を他のコンポーネントで使うにはpropsとして渡す必要がある
- globalにstateを持つ
- アプリ全体でスコープを持つ
- ストアを作り、専用のフックから必要に応じてデータを持ってくる
以上のように、特性が違う状態の管理方法から用途に合う方を選ぶ必要があります。
今回、認証の実装において、私はglobalに管理する方法を選択しました。
認証の状態をglobalに持つに至った理由
本アプリの構成では、認証の状態をglobalに持つことを選択したと述べました。
その理由は、以下の2点です。
-
localに状態をもつと、propsによるコンポーネント間の受け渡しが発生する - 認証の状態によってUIの切り替えが発生する = アプリ全体のUXとして影響が出ることを考える必要がある
localに状態をもつと、propsによるコンポーネント間の受け渡しが発生する
コードの可読性、および、責務の煩雑化を防ぐためです。useStateでlocalに状態を持つと、その定義したコンポーネントから、
headerLayoutfeatures(機能毎)
へと機能単位でそれぞれにpropsによるバケツリレーが発生してしまいます。
もっと言うと、認証の状態を参照する必要がある要素ごとにpropsで認証状態を運び続ける必要性が生まれ,
認証の責務の担い手が複雑になってしまうデメリットを抱えてしまうと言えます。
認証の状態によってUIの切り替えが発生する = アプリ全体のUXとして影響が出ることを考える必要がある
先述したユーザーの認証状態を管理する理由と同じで、ユーザーの状態によってUIを切り替える必要があるからです。
つまり、
ユーザーの状態によって触れるUIを切り替える = ❌機能単位 ⭕️アプリ全体
このことから、localで定義した状態をpropsでバケツリレーするのでなく、アプリ全体で認証の状態を共有されるべきであると考えました。
そのための手段として、ユーザーの認証状態はglobalに管理することを決めました。
zustandの採用に至った理由
本アプリにおいて、globalにstateを管理する手法として、状態管理ライブラリであるZustandを採用しました。
採用した理由は以下の3点です。
-
Zustandのstoreを用いることで、以下の3点の責務を一括して担える構造をしている点- 認証状態(authStatus)
- 現在のuserの情報
- selectorを通して使用するaction(login/logout)
-
認証状態をアプリ全体で共有し、責務の一元化を図るだけでなく、selectorを通して必要な
state/actionのみを取得できる点 -
selectorを通して、取得した
state/actionを使って、Protected Routeの概念に則ったRoute Guardを実現できる点。
2.で言う必要なstate / actionのみを取得することで責務を担保し、不要なレンダリングを防ぐことでUXを向上させられる点
今回は認証状態というアプリ全体へ影響する状態を扱うため、
Context APIよりも、selectorを利用して必要なstate / actionのみを呼び出せるzustandを採用しました。
アプリの実装方針と、認証状態の責務分割
本アプリの方針として、
認証そのものはRails / devise_token_authで行い、zustandではフロント側で扱う認証状態を管理する構成にしています。
また、globalに定義するユーザーの認証状態の責務は、userAuthStore.tsの中で、集約しています。
以下の要素を各コンポーネントから必要なタイミングで参照、呼び出すことで更新し、現在のユーザーの認証状態をstore内で完結できる構成にしています。
認証状態(authStatus)
現在のuserの情報
storeを更新するaction(login/logout)
実際のコード
src/app/store/userAuthStore.ts
・型定義部分
import { create } from 'zustand';
// リダイレクト理由の型定義
type RedirectedReason = 'login_require' | 'logged_out' | null;
interface User {
id?: number;
name?: string;
}
interface AuthState {
authStatus: 'unknown' | 'authenticated' | 'unauthenticated';
user: User | null;
redirectedReason: RedirectedReason;
setRedirectedReason: (reason: RedirectedReason) => void;
login: (user: User) => void;
logout: (reason?: RedirectedReason) => void;
clearRedirectedReason: () => void;
}
実際のコード
src/app/store/userAuthStore.ts
・専用フック部分
export const useAuthStore = create<AuthState>((set) => ({
authStatus: 'unknown',
user: null,
redirectedReason: null,
// リダイレクト理由を設定する関数
setRedirectedReason: (reason) => set({ redirectedReason: reason }),
// login関数は、引数でユーザー情報を受け取るようにし、状態を更新する際にユーザー情報をセットするロジックを追加
login: (user) => set({ authStatus: 'authenticated', user, redirectedReason: null }),
// logout関数は、引数でリダイレクト理由を受け取るようにし、状態を更新する際にその理由をセットする
logout: (reason = 'login_require') =>
set({ authStatus: 'unauthenticated', user: null, redirectedReason: reason }),
// 呼び出すとリダイレクト理由をクリアする関数
clearRedirectedReason: () => set({ redirectedReason: null }),
}));
authStatusの初期値について
authStatus: 'unknown', // ユーザーの初期状態をunknownとして定義
user: null,
redirectedReason: null, // ユーザーがリダイレクトした理由を定義、初期値はnull
// 「ログアウト後の遷移」「認証ガードに弾かれた」など
authStatus: unknown な理由は、ユーザーの状態が認証済み / 未認証の二択であると限らないから。
フロント側で認証を確定できてない場合の状態をも定義する必要がある。
unknownはこのユーザーの曖昧な状態を定義するものとして定めている。
なぜ、unknownの状態が必要か?
→ ユーザーの状態によってUIを切り替えているから
認証済み / 未認証のみでユーザーのUIを切り替えると、それ以外の曖昧な状態の時に表示するUIが存在しないことになる
→サーバーから応答の間隔によっては、ログイン画面や認証済み画面が一瞬切り替わるような不自然な表示となり、UXを損なう原因となるため、第三の状態としてunknownが必要となる
セレクター(selector)を使用して、必要な処理を呼び出す
// src/app/features/auth/hooks/useSignIn.tsx
export const useSignIn = () => {
// zustand の状態管理で使用するためのstateと関数
const login = useAuthStore((state) => state.login);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
// ログイン用パラメータ
const params: AuthParams = {
email,
password,
};
const res = await signIn(params);
const resUser = res.data.data;
const id = resUser.id;
const name = resUser.name;
toast.success('ログインに成功しました。');
// フロント側をログイン状態にする
-> login({ id: id, name: name });
// src/app/store/useAuthStore.ts
interface User {
id?: number;
name?: string;
}
interface AuthState {
authStatus: 'unknown' | 'authenticated' | 'unauthenticated';
user: User | null;
login: (user: User) => void;
logout: (reason?: RedirectedReason) => void;
}
useAuthStore.tsでは、ユーザーの認証状態に加え、ユーザーの情報も集約しています。
login({ id: id, name: name });
interfaceに定義したid, nameをloginに渡すことで、store内のユーザー情報を更新できます。
これにより、以下のようなコンポーネントの中でstoreを参照することで、現在のユーザー情報を利用したUIを作ることができます。
- header
- Layout
- features
React Routerとの連携による、画面制御とUXについて
PrivateLayoutとPublicLayoutによる画面制御
認証の状態をZustandを使ってglobalに管理することで、アプリ全体からstoreにある現在のユーザーの状態を参照することができるようになりました。
これにより、ユーザーの状態によってUIを実際に切り替えることが可能になったので、PrivateLayout(認証済み)とPublicLayout(未認証)の切り替えを実装しました。
React-Routerを使用してのRoute Guardの実装
Routerの構成
// src/App.tsx
<Router>
<Routes>
{/* 公開レイアウト */}
<Route element={<PublicLayout />}>
<Route path="signIn" element={<SignIn />} />
<Route path="signUp" element={<SignUp />} />
</Route>
{/* 認証後のレイアウト */}
<Route element={<PrivateLayout />}>
<Route index element={<Home />} />
</Route>
<Route path="*" element={<h1>StatusCode-404 Not Found Page</h1>} />
</Routes>
</Router>
上記の構成から,
<PublicLayout />の配下に<SignIn /> , <SignUp />が置かれている。
<PrivateLayout />の配下に<Home />(ルート)が置かれている。
// src/pages/private/PrivateLayout.tsx
if (authStatus === 'unknown') {
return <Loading />;
}
if (authStatus === 'unauthenticated') {
// リダイレクト理由に応じたトースト表示のための変数
const toast = redirectReason ?? 'login_require';
// 認証されていない場合、サインインページにリダイレクト
return <Navigate to="/signIn" replace state={{ toast, from: location.pathname }} />;
}
// front/App_front/src/pages/public/PublicLayout.tsx
if (authStatus === 'authenticated') {
// 認証されている場合、ホームページにリダイレクト
return <Navigate to="/" replace />;
}
-
unknown状態の時は<Loading />= ローディングアニメーションを表示するコンポーネントを呼び出しています。
-
unauthenticated状態(=未認証)の時は、Navigateで/signInにリダイレクトされる構成にしています
-
authenticated状態(認証済み)の時は、Navigateで/にリダイレクトされる構成にしています
これにより、Routeの単位での認証Guardを利用して、認証されてない限りPrivateLayoutへは到達しない構成を実現しています。
redirectReason(ページ遷移理由を状態として定義)
を利用し、toast側(UI)へ渡すことで、ログアウト時・認証ガード時でtoastの表示UIを出し分けています。
実装してみてわかったこと
zustand は、
・ 「globalに共有される状態」と「その状態を変更する操作」を
単一の store に閉じ込めることで責務をその中で完結させることができます。
・ このstoreによって、各コンポーネントは selector を通じて、必要なaction/責務だけを抽出してUIを構築、使用できる仕組みが提供されます。
Server側の状態管理について
今回zustandで管理したのは、
フロント側で扱う認証状態です。
一方で、
APIから取得する一覧データなどのserver stateについては、
TanStack Queryを使用して別責務として管理しています。
server state側の設計については、
別記事で整理したいと思います。
終わりに
ここまで読んでいただき、ありがとうございました。
本アプリの開発を通して非常の多くの気づき、知見を得ましたので随時執筆を行い、公開していきたいと思います。
GitHubリンク: https://github.com/yuji-2293/ColorMirror_Re
アプリURL: https://color-mirror-re.vercel.app/
次回 Server stateの責務分離について
