第198章:認証エラーのハンドリング
ログイン機能って、成功より失敗のほうが体験の差が出やすいんだよね…!😵💫 この章では「ログイン失敗した時に、ユーザーにやさしく&安全に伝える」仕組みを作るよ〜🫶✨
今日のゴール🎯✨
- エラーを 「種類ごと」 に分ける(入力ミス / ネット不調 / 試しすぎ / その他)📦
- 表示するメッセージを 日本語でわかりやすく する☺️💬
- セキュリティ的にNGな「ユーザーが存在する/しない」バレを避ける🕵️♀️🚫
- React 19 の Form Actions + useActionState で「送信中」「失敗」をスッキリ管理する🧼✨ (React)
まず全体像(図解)🗺️✨
大事ポイント(ここだけは覚えて🧠🔑)
① “存在するメールです/存在しません” を言わない🙅♀️
「ユーザーがいない」「パスワードが違う」を別々に出すと、悪い人がメールアドレスの存在チェックに使えちゃう…😇 なので ログイン失敗は基本まとめて 👇みたいにするのが安全🛡️
- ✅「メールアドレスかパスワードが違います」
Firebase JS SDK では、複数の失敗理由が auth/invalid-credential に寄ることもあるよ(仕様変更の経緯もリリースノートに記載あり)(Firebase)
② “同じ操作をすぐリトライしても成功しない” ことが多い🔁💥
ネットワーク系や “試しすぎ” 以外は、同じ入力のまま連打しても改善しないことが多いよ〜😵💫 (Firebase でも「ネットワーク/TooManyRequests 以外はリトライしても成功しない」系の注意がある)(Firebase)
実装①:エラーを「UI用」に変換する関数を作ろう🧑🍳✨
📁 src/features/auth/authError.ts を作るイメージ!
// src/features/auth/authError.ts
export type AuthUiError =
| { kind: "invalid_email"; message: string; field: "email" }
| { kind: "invalid_credentials"; message: string }
| { kind: "too_many_requests"; message: string }
| { kind: "network"; message: string }
| { kind: "unknown"; message: string; debug?: string };
// FirebaseError に限らず「code を持つエラー」を安全に判定するタイプガード
function hasStringCode(err: unknown): err is { code: string; message?: string } {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
typeof (err as { code: unknown }).code === "string"
);
}
export function toAuthUiError(err: unknown): AuthUiError {
if (!hasStringCode(err)) {
return { kind: "unknown", message: "ログインに失敗しました…もう一度試してね🙏" };
}
const code = err.code;
switch (code) {
case "auth/invalid-email":
return {
kind: "invalid_email",
field: "email",
message: "メールアドレスの形がちょっと変かも…💦 もう一度チェックしてね🙂",
};
// ✅ セキュリティのため、存在チェックにつながる系は “まとめて” 同じ表示に寄せる
case "auth/invalid-credential":
case "auth/wrong-password":
case "auth/user-not-found":
return {
kind: "invalid_credentials",
message: "メールアドレスかパスワードが違います…🥺",
};
case "auth/too-many-requests":
return {
kind: "too_many_requests",
message: "試しすぎちゃったかも…⏳ 少し待ってからもう一度やってみてね!",
};
case "auth/network-request-failed":
return {
kind: "network",
message: "ネットワークが不安定みたい…📶 Wi-Fi/回線を確認してね!",
};
default:
return {
kind: "unknown",
message: "ログインに失敗しました…🙏 時間をおいて再度試してみてね。",
debug: `${code}${err.message ? `: ${err.message}` : ""}`,
};
}
}
補足:auth/invalid-credential が出るのは近年よくある挙動(複数の失敗理由がここに集約されるケース)(Firebase)
実装②:React 19 の Form Actions + useActionState で「送信中・失敗」を管理する🧡⚡
1) 送信ボタン(送信中は無効化)🔘⏳
// src/features/auth/SubmitButton.tsx
import { useFormStatus } from "react-dom";
type Props = { label: string };
export function SubmitButton({ label }: Props) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "送信中…⏳" : label}
</button>
);
}
useFormStatus は React 19 の Actions と一緒に使う想定の仕組みだよ〜🧩 (React)
2) ログインフォーム本体(失敗メッセージを表示)🧾✨
// src/features/auth/LoginForm.tsx
import { useActionState, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/lib/firebase"; // 既に作ってある前提でOK
import { toAuthUiError } from "./authError";
import { SubmitButton } from "./SubmitButton";
type FormState =
| { ok: true }
| { ok: false; message: string; field?: "email" | "password" };
const initialState: FormState = { ok: false, message: "" };
async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
try {
await signInWithEmailAndPassword(auth, email, password);
return { ok: true };
} catch (err: unknown) {
const ui = toAuthUiError(err);
// 開発中だけ「原因」を見たいとき用(本番でそのまま表示はしない!🚫)
if (ui.kind === "unknown" && ui.debug) {
console.error("Auth error(debug):", ui.debug);
}
// email の形が変な時は field を email に寄せてあげると親切🙂
if (ui.kind === "invalid_email") {
return { ok: false, message: ui.message, field: "email" };
}
return { ok: false, message: ui.message, field: "password" };
}
}
export function LoginForm() {
const navigate = useNavigate();
const [state, formAction, isPending] = useActionState(loginAction, initialState);
const [touched, setTouched] = useState(false);
useEffect(() => {
if (state.ok) navigate("/"); // ログイン後の遷移
}, [state.ok, navigate]);
const showError = touched && !state.ok && state.message;
return (
<div style={{ maxWidth: 420 }}>
<h1>ログイン🔐✨</h1>
<form
action={async (fd) => {
setTouched(true);
await formAction(fd);
}}
aria-busy={isPending}
>
<label>
メールアドレス📧
<input
name="email"
type="email"
autoComplete="email"
aria-invalid={showError && state.field === "email" ? true : undefined}
/>
</label>
<label>
パスワード🔑
<input
name="password"
type="password"
autoComplete="current-password"
aria-invalid={showError && state.field === "password" ? true : undefined}
/>
</label>
{showError ? (
<p role="alert" aria-live="polite">
{state.message}
</p>
) : null}
<SubmitButton label="ログインする🚀" />
</form>
</div>
);
}
useActionState は「フォームアクションに紐づく状態(成功/失敗/送信中)」をまとめて扱える仕組みだよ〜✨ (React)
図解:フォーム送信のやりとり(シーケンス)📨✨
ちょい足しTips(プロっぽくなる✨)🌟
- ✅ 「送信中…」の間はボタン無効化(二重送信防止)🔒
- ✅ エラー文は 生の英語メッセージを出さない(怖い&わかりにくい)😵💫
- ✅
auth/too-many-requestsは「待ってね」+「パスワード再設定導線」も相性◎🕒✨ - ✅
unknownはユーザーには短く、開発者にはconsole.errorや監視ツールへ🧯📈
(おまけ)Supabase の場合は?🐳💡
Supabase Auth はエラーコード一覧が公式にまとまってるよ(まずここを見るのが最短)(Supabase) やり方は同じで、**「error を UI 用に変換する関数」**を1個作ればOK🙆♀️✨
ミニ課題(5〜10分)🧪✨
auth/too-many-requestsのときだけ、メッセージの下に 「パスワードを忘れた?」リンクを出してみよう🔗🙂state.fieldに合わせて、aria-invalidだけじゃなく 見た目も赤枠にしてみよう❤️🔥
必要なら、この章の続きとして「トースト通知(Shadcn UI / Sonner)でエラーを出す版」も同じ設計でサクッと作れるよ〜🍞✨