【React】Reactアプリに認証機能を実装するパターン

JavaScript

こんにちは、かつコーチです。

前回はReact Hook Formを使ったフォームバリデーションの実装方法を解説しました。

フォームでログイン情報を受け取れるようになったところで、今回はいよいよ「認証」を扱います。

認証はライブラリの使い方だけでなく、アプリ全体の設計に関わるテーマなので、上級者向けの内容として全体像から丁寧に整理していきます。

Reactアプリにおける認証の全体像

認証と認可の違い

まず整理しておきたいのが、認証(Authentication)認可(Authorization)という似た言葉の違いです。

  • 認証:そのユーザーが「誰であるか」を確認すること(ログイン処理そのもの)
  • 認可:認証されたユーザーが「何をしてよいか」を判断すること(管理者だけがアクセスできるページ、など)

この2つを混同すると設計がぶれやすくなります。

たとえば「ログインしていないユーザーを弾く」のは認証の話ですが、「ログインはしているが管理者権限がないユーザーを弾く」のは認可の話です。

フロントエンドが担う役割・担わない役割

ここは私が初めて認証機能を実装したときに強く実感したポイントですが、Reactだけで「本当のセキュリティ」は実現できません

Reactが担うのは、あくまで「ログインしていないユーザーにはこのページを表示しない」というUI上の制御です。

実際のデータの保護(本当にそのユーザーがアクセスしてよいデータかどうかのチェック)は、必ずAPIサーバー側でも行う必要があります。

フロントエンドの認証制御を「見た目上の案内係」、サーバー側の認可チェックを「本当の門番」と考えると役割分担がイメージしやすいです。

代表的な実装パターン

パターン1:Context APIで認証状態を管理する

小〜中規模のアプリでよく使われるのが、Context APIでログインユーザーの情報をアプリ全体に共有するパターンです。

import { createContext, useContext, useState, ReactNode } from "react";

type AuthUser = {
  id: number;
  name: string;
};

type AuthContextValue = {
  user: AuthUser | null;
  login: (user: AuthUser) => void;
  logout: () => void;
};

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = useState<AuthUser | null>(null);

  const login = (loggedInUser: AuthUser) => setUser(loggedInUser);
  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuthはAuthProviderの内側で使ってください");
  return context;
};

このAuthProviderでアプリ全体を囲んでおけば、どのコンポーネントからもconst { user, login, logout } = useAuth()でログイン状態にアクセスできます。

パターン2:React Query + Contextでユーザー情報をキャッシュする

以前の記事で紹介したReact Queryを組み合わせると、「ログインユーザーの情報をサーバーから取得してキャッシュする」処理も統一的に扱えます。

import { useQuery } from "@tanstack/react-query";

type AuthUser = {
  id: number;
  name: string;
};

const fetchCurrentUser = async (): Promise<AuthUser | null> => {
  const res = await fetch("/api/me", { credentials: "include" });
  if (res.status === 401) return null; // 未ログイン
  if (!res.ok) throw new Error("ユーザー情報の取得に失敗しました");
  return res.json();
};

const useCurrentUser = () => {
  return useQuery({
    queryKey: ["currentUser"],
    queryFn: fetchCurrentUser,
    staleTime: 1000 * 60 * 5,
  });
};

export default useCurrentUser;

こうしておくと、ページを再読み込みしてもログイン状態を自動的にサーバーへ問い合わせて復元でき、なおかつキャッシュのおかげで無駄な通信も抑えられます。

よくあるつまずきポイント:初期化タイミングでUIがちらつく

ログイン判定が終わる前に画面が切り替わってしまう

認証機能を初めて実装したとき、私が実際にはまったのがこの「ちらつき」問題でした。

❌ Before:ローディング状態を考慮せずに分岐する

const App = () => {
  const { user } = useAuth();

  // userの初期値がnullなので、最初は必ずログイン画面が一瞬表示されてしまう
  return user ? <Dashboard /> : <LoginPage />;
};

userの初期値をnullにしていると、実際にはログイン済みのユーザーであっても、サーバーへの問い合わせが終わるまでの一瞬だけ「未ログイン」と判定され、ログイン画面が一瞬表示されてから本来の画面に切り替わる、という現象が起きます。

私はこれをリリース直前に発見し、ログイン済みのユーザーが毎回一瞬ログイン画面を見せられる状態のまま公開しそうになったことがあります。

✅ After:認証状態の確認が終わるまでローディング画面を出す

const App = () => {
  const { data: user, isLoading } = useCurrentUser();

  if (isLoading) return <p>読み込み中です...</p>;

  return user ? <Dashboard /> : <LoginPage />;
};

「ログイン状態を問い合わせ中」というローディング状態を明示的に持ち、その間はどちらの画面も表示しないようにすることで、ちらつきを防げます。

認証状態は「ログイン済み/未ログイン」の2択ではなく、「確認中/ログイン済み/未ログイン」の3状態で管理するのが実務での定石です。

応用・一歩先の使い方

ログイン後のリダイレクト設計

ログインが成功した後は、単純にトップページへ戻すのではなく、「もともとアクセスしようとしていたページ」に戻すとUXが向上します。

import { useNavigate, useLocation } from "react-router-dom";

const LoginPage = () => {
  const navigate = useNavigate();
  const location = useLocation();
  const from = (location.state as { from?: string })?.from ?? "/";

  const handleLoginSuccess = () => {
    navigate(from, { replace: true });
  };

  // ログインフォームの実装は省略
  return <div onClick={handleLoginSuccess}>ログイン処理</div>;
};

未ログインのままアクセスしようとしたページのパスをlocation.stateに持たせておき、ログイン成功後にそこへ戻す、という実装がよく使われます。

ログアウト・トークン失効時の共通処理

トークンの有効期限が切れた場合など、API側から401(Unauthorized)が返ってきたら自動的にログアウト状態にする共通処理を仕込んでおくと安全です。

const apiFetch = async (url: string, options?: RequestInit) => {
  const res = await fetch(url, { ...options, credentials: "include" });

  if (res.status === 401) {
    // 共通のログアウト処理を呼び出す
    window.location.href = "/login";
    throw new Error("認証の有効期限が切れました");
  }

  return res;
};

すべてのAPI呼び出しをこのapiFetch経由にしておけば、トークン切れの検知漏れを防げます。

次回は、この認証処理の根幹を支えるJWT(JSON Web Token)の扱い方を詳しく見ていきます。

まとめ

この記事のポイント

  • 認証(誰であるか)と認可(何をしてよいか)は別の概念として整理する
  • 本当のセキュリティはサーバー側で担保し、フロントエンドはUI上の制御に徹する
  • Context APIやReact Queryを使い、ログインユーザーの状態をアプリ全体で共有する
  • 認証状態は「確認中/ログイン済み/未ログイン」の3状態で管理し、ちらつきを防ぐ
  • 401エラー時の共通ログアウト処理を仕込んでおくと、トークン失効に安全に対応できる

次に読むべき記事

→ 次の記事:JWTの扱い方:ログイン状態を保持する仕組み

タイトルとURLをコピーしました