React.js 例外処理の設計1(捕捉して変換する)
概要
下記のような、場当たり的な例外処理をやめたいので、例外処理の設計を考えてみました。
この記事ではReact.jsを対象としています。
※ちなみ「例外」と「エラー」を同じ意味で使っています。
例:
try {
await mutation() // REST API POST処理 or GraphQL Mutation処理
await router.push('/path/to/page')
} catch (e) {
console.error(e); // コンソールエラー出力して終わっていたり、
HogeErrorHandler(e as HogeError); // 急に型キャストして無理やり関数呼び出していたりします。自由すぎやろ。
// ↓
// チームで認識合わせられるように例外処理を定型化したい!
}
参考: 「エラー」と「例外」
対象読者
- JavaScriptのフレームワークを使って開発している方
- 例外処理を雑に処理している方
- ログの出力形式が統一されていないため調査が捗らない方
いいね!してね
この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!
それでは以下が本編です。
結論
- 例外を分類する
- 発生した箇所でtry-catchする(アプリケーション定義のエラー型に変換する)
- APIエラーは、リポジトリ層の関数のreturn値に(変換後の)エラーをつめる
- その他ランタイムエラーはErrorBaundaryでcatchして処理する
説明すること
- 例外の発生パターンを理解する
- 例外処理の基本方針
- ログ出力する
例外の発生パターンを理解する
例外は、下記のような5パターンが存在します。
番号 | パターン | 説明 |
---|---|---|
1 | コンポーネントレンダリング中に発生するエラー(予期せぬエラー) | tsxでコンポーネント実行中に発生するエラー。変数に予期せぬ値が入っていてRuntimeエラー |
2 | コンポーネントレンダリング中に発生するエラー(予期できるエラー) | tsxでコンポーネント実行中に発生するエラー。キャッシュが存在しないなど、想定できるエラー |
3 | 非同期関数が完了しなかったときに発生するエラー | Promiseな関数が解決せずに破棄された時に発生するエラー(unhandledRejectionError) |
4 | APIエラーレスポンス | APIのレスポンスが400以上でエラーが返るケース |
5 | ユーザー入力に関するエラー | フォームのバリデーションエラー(フロントエンド) |
パターンの具体例を説明していきます。
1 コンポーネントレンダリング中に発生するエラー(予期せぬエラー)
変数がnullやundefinedになっていてアクセスしようとした時に発生するやつです!
具体例
type UserProfileProps = {
user: {
name: string;
age: number;
}
}
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
// 予期せぬエラー: なぜかわからないが、userがnullの場合がある
return (
<div>
<h1>{user?.name}</h1>
<p>Age: {user?.age}</p>
</div>
);
};
2 コンポーネントレンダリング中に発生するエラー(予期できるエラー)
想定するキャッシュが存在しないなど、想定できる例外です!
具体例
入力画面 -> 入力内容確認画面 -> 完了画面という画面遷移をするケース
前のページで入力した値をreduxやcookieなど(ブラウザのキャッシュ)に保存していたが、消えてしまったケース
import React, { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
interface UserProfile {
name: string;
age: number;
email: string;
}
const ProfileConfirmation: React.FC = () => {
const [profile, setProfile] = useState<UserProfile | null>(null);
useEffect(() => {
try {
// Cookieからプロフィール情報を取得
const savedProfile = Cookies.get('userProfile');
if (!savedProfile) {
throw new Error('Profile data not found in cookies'); // 予期できるエラー
}
} catch (err) {
console.error(err)
}
}, []);
return (
<div>
<h1>Confirm Your Profile</h1>
<p>Name: {profile.name}</p>
<p>Age: {profile.age}</p>
<p>Email: {profile.email}</p>
</div>
);
};
3 非同期関数が完了しなかったときに発生するエラー
async/awaitが不完全なため、Networkを遅くするとsubmit後にボタンが再度活性化してしまいます。で、多重にボタンをクリックすると、コンポーネントがunmountされます。
その結果、クリック時に実行しようとしたPromiseを返す関数が途中で破棄されます。unhandledRejectionErrorが発生します。
具体例
const Template: FC = () => {
const router = useRouter();
const {
handleSubmit,
formState: { isSubmitting, isValid},
} = useResignMemberForm();
const resignMemberMutation = useResignMember();
const submitHandler = async (
data: ResignMemberSchema,
event?: BaseSyntheticEvent,
) => {
event?.preventDefault?.();
// とても重い処理
await resignMemberMutation({
reasonType: data.reasonType,
reasonDetail: data.reasonDetail,
agreement: data.agreement,
});
// awaitつけ忘れ
router.push(publicPages.index.path());
};
return (
<div>
<form
onSubmit={handleSubmit((data, event) => submitHandler(data, event))}
>
<button type="submit" disabled={!isValid || isSubmitting}>
退会する
</button>
</form>
</div>
);
};
4 APIレスポンス
APIのレスポンスが400以上でエラーが返るケースです。
※フォームのバリデーションエラー(バックエンド)はこちらに含まれます
const UpdateProfile: React.FC = () => {
const [updateProfile] = useMutation(UPDATE_PROFILE);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const { data } = await updateProfile({ variables: profile });
// 成功した場合の処理
alert("Profile updated successfully!");
} catch (e: any) {
// ApolloErrorオブジェクトからエラーメッセージを取得
console.error(e);
}
};
...割愛
};
5 ユーザー入力に関するエラー
フロントエンドで行うバリデーションエラー
例: フォームの入力値のバリデーションエラーなど
これはreact-hook-formなどのライブラリを使って実装します。
ライブラリがよしなにやってくれるので、特段取り上げません。
例外処理の基本方針
例外の補足方法
一般的な例外補足パターンは下記の3つが存在します。
1 Javaのような「try-catchを利用した大域脱出」
2 関数型言語のように「Option/Eitherを使う方式」
3 Go言語のように単純に「戻り値で表現する」パターン
で、JavaScriptは、1のようにtry-catchを使うようです。
※Javaのように関数に対して発生しうる例外の型を指定できません。
public static void processData(String filePath) throws FileNotFoundException の部分
では、どうすべきでしょうか?
コンポーネント内のRuntime Errorは、1の「try-catch」を採用し、外側のコンポーネント(ErrorBoundary)でキャッチする
それ以外は、3の「戻り値で表現する」を採用し、呼出元でエラーを処理させる
で対応していきたいです!
参考記事
例外の種類とエラー補足方法の対応関係
番号 | パターン | 説明 | 処理の流れ |
---|---|---|---|
1 | コンポーネントレンダリング中に発生するエラー(予期せぬエラー) | tsxでコンポーネント実行中に発生するエラー。変数に予期せぬ値が入っていてRuntimeエラー | ①全体のErrorBaundaryで捉えて処理する |
2 | コンポーネントレンダリング中に発生するエラー(予期できるエラー) | tsxでコンポーネント実行中に発生するエラー。キャッシュが存在しないなど、想定できるエラー | ②個別のErrorBaundaryで捉えて処理する |
3 | 非同期関数が完了しなかったときに発生するエラー | Promiseな関数が解決せずに破棄された時に発生するエラー(unhandledRejectionError) | ③全体のErrorBaundaryで捉えて処理する |
4 | APIエラーレスポンス | APIのレスポンスが400以上でエラーが返るケース | ④関数内でtry-catchで捉えて処理する |
↓
①全体のErrorBaundaryで捉えて処理する
これはReact公式が説明している機能ですね!
具体例
export const AppProvider = ({
children,
}: { children: ReactNode }): JSX.Element => {
return (
<ErrorBoundary> // 外側で囲むことでコンポーネント内の例外をcatchする
<AppApolloProvider>
<AuthProvider>{children}</AuthProvider>
</AppApolloProvider>
</ErrorBoundary>
);
};
type ErrorBoundaryState = { error?: Error };
type ErrorBoundaryProps = { children: ReactNode };
export class ErrorBoundary extends Component<ErrorBoundaryProps> {
state: ErrorBoundaryState = {
error: undefined,
};
// biome-ignore lint/complexity/noUselessConstructor: <explanation>
constructor(props: ErrorBoundaryProps) {
super(props);
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(err: Error, errInfo: ErrorInfo) {
this.setState(() => {
return {
error: { err, errInfo },
};
});
}
render() {
if (this.state.error) {
return (
<ErrorScreen
error={this.state.error}
onReset={() => {
this.setState(() => {
return {
error: undefined,
};
});
}}
/>
);
}
return this.props.children;
}
}
②個別のErrorBaundaryで捉えて処理する
特定のユースケースに対応する個別ErrorBaundaryを用意します。
const Template = () => {
// cacheから値を取得して、存在しない場合にエラーを発生させる処理
}
export default function UpdateProfileConfirm() {
return (
<UpdateProfileConfirmErrorBoundary>
<Template />
</UpdateProfileConfirmErrorBoundary>
);
}
+
updateProfileConfirmErrorBaundary.tsx(ユースケースに対応するように一部修正した①を使います)
③全体のErrorBaundaryで捉えて処理する
①に追加でイベント追加します
componentDidMount() {
// 非同期処理内で発生したエラー Uncaught (in promise) をキャッチする
window.addEventListener(
"unhandledrejection",
(e: PromiseRejectionEvent) => {
throw transformUnhandledRejectionError(e.reason);
},
);
}
componentWillUnmount() {
window.removeEventListener(
"unhandledrejection",
(e: PromiseRejectionEvent) => {
throw transformUnhandledRejectionError(e.reason);
},
);
}
④関数内でtry-catchで捉えて処理する
例外が発生した場合は、Repositoryでcatchして、変換して返します。
/**
* Mutations
*/
type UseUpdateMemberStatusType = {
data: UpdateMemberStatusInputType["activityInput"] | null;
error: AppErrorMessage | null; // エラーになった場合の型
};
export type UpdateMemberStatusInputType = {
activityInput: {
status: MemberStatus;
memberId: string;
memberResigned: {
data: {
memberId: string;
reasonType: string;
agreement: boolean;
reasonDetail: string | null;
};
};
};
};
type UpdateMemberStatusType = (
variables: UpdateMemberStatusInputType,
) => Promise<UseUpdateMemberStatusType>;
export const useUpdateMemberStatus = (): UpdateMemberStatusType => {
const [mutate] = useResignMemberMutation();
return async (variables: ResignMemberMutationVariables) => {
try {
const res = await mutate({ variables });
// データも必要に応じて、即時変換する
return { data: resignMemberTransform(res), error: null };
} catch (error) {
// 例外は即時変換する
return { data: null, error: transformError(error) };
}
};
};
const Template: FC = () => {
const router = useRouter();
const cache = useMemo(() => getCache(sessionKeys.resignMember), []);
const {
handleSubmit,
register,
formState: { isSubmitting, isValid, errors },
} = useResignMemberForm({
defaultValues: cache,
});
const mutate = useUpdateMemberStatus();
const submitHandler = async (
data: ResignMemberSchema,
event?: BaseSyntheticEvent,
) => {
event?.preventDefault?.();
const res = await mutate({
activityInput: {
status: memberStatus.resigned,
// TODO: ログインメンバーのID
memberId: "ff4b01ee-15e9-4e2e-acb3-25a0347af7c1",
memberResigned: {
data: {
// TODO: ログインメンバーのID
memberId: "ff4b01ee-15e9-4e2e-acb3-25a0347af7c1",
reasonType: data.reasonType,
agreement: data.agreement,
reasonDetail: data.reasonDetail,
},
},
},
});
if (res.error) {
// エラーがある場合は、AppErrorMessage型が必ず返ってくるため、エラー処理を行う
}
await router.push(publicPages.index.path());
};
...割愛
};
ログ出力する・アラートを飛ばす
基本的な処理の流れが固まってきたところで、次はログ出力・アラートという観点で改善していきたいです。
検査例外 vs 非検査例外
「検査例外」「非検査例外」という概念がある。下記のような理解で進めます。
検査例外: 予期できる例外。事前に問題に対処するためのコードを書くべきものです。
非検査例外: 予期できないことが多い。データやロジックの誤り。テストコードなどで防げるものもあります。
種類 | 説明 | 具体的な例 | 扱い方 |
---|---|---|---|
検査例外 | プログラムの実行時に発生が予見される問題を表し、適切に捕捉する | firebase auth サインアップメールアドレスが既に使われている{ name: "FirebaseError", code: 'auth/email-already-in-use' }
|
予期できる。事前に問題に対処するためのコードを書くべき。 |
非検査例外 | 主にプログラムのRuntimeに発生するバグ。例えば配列の範囲外アクセスやnullオブジェクトの参照操作が含まれる。 |
ReferenceError: hoge is not defined Cannot read property 'name' of undefined
|
予期できないことが多い。データやロジックの誤り。 |
業務例外 vs システム例外
さらに、業務例外とシステム例外を明確に区別することで、
「ログ記録、ログレベル、アラート要否、ユーザーが復帰できるか」の観点で適切な対応を行うことができます。
業務/システム | 検査/非検査 | ログ記録(level) | アラート | ユーザーが復帰できるか | 例 |
---|---|---|---|---|---|
業務例外 | 検査例外 | しない | しない | できる(原因を表示する) | フォームのバリデーションエラー |
業務例外 | 検査例外 | しない | しない | できる(原因を表示する) | APIレスポンス firebase auth サインアップメールアドレスが既に使われている (4 APIエラーレスポンス) |
業務例外 | 検査例外 | しない | しない | できる(原因を表示する) | APIレスポンス firebase auth ログインメールアドレスが間違っている (4 APIエラーレスポンス) |
業務例外 | 検査例外 | しない | しない | できる(ErrorBaundaryが表示する画面内に導線を用意する) | 前ページでセットしたキャッシュがない (2 コンポーネントレンダリング中に発生するエラー 予期できるエラー) |
システム例外 | 非検査例外 | する(error) | しない | できる(原因を表示する。再試行してもらう) | 非同期処理が完了する前にエラーが発生した unhandledRejection(例: 非同期処理が含まれたボタンの複数回クリック) (3 非同期関数が完了しなかったときに発生するエラー) |
システム例外 | 非検査例外 | する(error) | しない | できる(原因を表示する。再試行してもらう) | クライアント側のネットワークエラー (4 APIエラーレスポンス) |
システム例外 | 非検査例外 | する(fatal) | する | できない | 予期せぬ値だったため例外が発生した (1 コンポーネントレンダリング中に発生するエラー 予期せぬエラー) |
システム例外 | 非検査例外 | する(fatal) | する | できない | firebase auth サーバーが止まっているなど、外部APIレスポンスの500系エラー (4 APIエラーレスポンス) |
システム例外 | 非検査例外 | する(fatal) | する | できない | データベース接続エラーなど、500系エラー (4 APIエラーレスポンス) |
例外処理用の定義を追加する
上記の要件を満たすように、例外処理用の内部変数の型を定義する。
例外処理用の内部変数
export type RuntimeError = {
cause?: unknown | string;
stack?: string;
};
export type AppErrorMessage = {
title?: string;
message?: string;
myErrorCode: string;
level: "fatal" | "error" | "warning" | "info" | "debug";
alert?: boolean;
runtime?: RuntimeError;
};
export type AppServerErrorMessage = AppErrorMessage & {
status: number;
};
export const APP_ERROR = {
SYSTEM: {
RECOVERABLE: {},
UNRECOVERABLE: {
EE98: {
// 非同期処理が完了しなかったエラー
title: "エラーが発生しました",
message:
"しばらく時間をおいてから再度お試しください。解消しない場合は開発者にお問い合わせください",
myErrorCode: "EE98",
level: "fatal",
},
EE99: {
// 予期せぬエラー
title: "エラーが発生しました",
message:
"しばらく時間をおいてから再度お試しください。解消しない場合は開発者にお問い合わせください",
myErrorCode: "EE99",
level: "fatal",
},
},
},
BUSINESS: {
RECOVERABLE: {
AU10: {
// firebaseエラー
title: "サインアップエラー",
message: "メールアドレスはすでに利用されています。",
myErrorCode: "AU10",
level: "error",
},
AU20: {
// firebaseエラー
title: "ログインエラー",
message: "メールアドレスまたはパスワードが間違っています。",
myErrorCode: "AU20",
level: "error",
},
H400: {
// 不正なリクエスト
title: "Bad Request",
message: "不正なリクエストです。",
myErrorCode: "H400",
level: "error",
},
H401: {
// 認証エラー
title: "Unauthorized",
message: "認証エラー",
myErrorCode: "H401",
level: "error",
},
H403: {
// アクセス禁止
title: "Forbidden",
message: "アクセスが禁止されています。",
myErrorCode: "H403",
level: "error",
},
H404: {
// リソースがない
title: "Not Found",
message: "リソースが見つかりませんでした。",
myErrorCode: "H404",
level: "error",
},
H408: {
// タイムアウト
title: "Request Timeout",
message: "タイムアウトしました。",
myErrorCode: "H408",
level: "error",
},
},
UNRECOVERABLE: {
H500: {
// サーバーエラー
title: "Internal Server Error",
message: "サーバーエラーが発生しました。",
myErrorCode: "H500",
level: "fatal",
},
AU99: {
// firebaseエラー
title: "ネットワークエラー",
message:
"認証処理でネットワークエラーが発生しました。時間をおいて再度試してください",
myErrorCode: "AU99",
level: "fatal",
},
},
},
} as const;
ロギング用の関数
import { isSentryEnabled } from "@/config/env";
import { Logger } from "@/lib/logger";
import * as Sentry from "@sentry/nextjs";
import type { AppErrorMessage } from "./const";
const logging = (error: AppErrorMessage) => {
switch (error.level) {
case "fatal":
new Logger().fatal(error);
break;
case "error":
new Logger().error(error);
break;
case "info":
new Logger().info(error);
break;
case "debug":
new Logger().debug(error);
break;
default:
new Logger().info(error);
}
};
const sendLog = (error: AppErrorMessage) => {
if (isSentryEnabled && ["fatal", "error"].includes(error.level)) {
Sentry.captureException(error);
}
};
export const outputErrorLog = (error: AppErrorMessage) => {
logging(error);
sendLog(error);
};
ユースケースごとの処理
- error/transform配下に「エラー変換処理関数」を追加します。
- 各ユースケースファイルと同じフォルダ内に「ユースケースごとのエラー変換処理関数」を追加します。「エラー変換処理関数」を内部で呼び出して、ユースケースごとのエラー変換を行います。
APIリクエストエラー(firebase認証)
...
const submitHandler = async (
data: SignUpSchema,
event?: BaseSyntheticEvent,
) => {
event?.preventDefault?.();
const res = await signUp({
email: data.email,
password: data.password,
});
if (res.error) {
outputErrorLog(res.error); // ログ出力する
}
await router.push(loginRequiredPages.mypage.path());
};
...
export type SignUpProps = {
email: string;
password: string;
};
type ResultType = { data: boolean | null; error: AppErrorMessage | null };
export const signUp = async (signUpProps: SignUpProps): Promise<ResultType> => {
try {
const auth = getAuth();
const userCredential = await createUserWithEmailAndPassword(
auth,
signUpProps.email,
signUpProps.password,
);
await sendEmailVerification(userCredential.user);
return { data: true, error: null };
} catch (error) {
return { data: null, error: transformError(error) }; // ここで変換する
}
};
ユースケースごとのエラー変換処理関数
import type { AppErrorMessage } from "@/error/const";
import { transformClientAuthError } from "@/error/transform/auth/transform";
import { transformUnexpectedError } from "@/error/transform/unexpected/transform";
import { FirebaseError } from "firebase/app";
export const transformError = (error: unknown): AppErrorMessage => {
if (error instanceof FirebaseError) {
const res = transformClientAuthError(error);
if (res) return res;
}
return transformUnexpectedError(error);
};
エラー変換処理関数
import { APP_ERROR, type AppErrorMessage } from "@/error/const";
import { FirebaseError } from "firebase/app";
export const transformClientAuthError = (
error: unknown,
): AppErrorMessage | undefined => {
if (error instanceof FirebaseError) {
// サインアップ系
if (error.code === "auth/email-already-in-use") {
return APP_ERROR.BUSINESS.RECOVERABLE.AU10;
}
// サインイン系
if (error.code === "auth/user-not-found") {
return APP_ERROR.BUSINESS.RECOVERABLE.AU20;
}
// ネットワーク系
if (error.code === "auth/network-request-failed") {
return APP_ERROR.BUSINESS.UNRECOVERABLE.AU99;
}
}
};
import {
APP_ERROR,
type AppServerErrorMessage,
type RuntimeError,
} from "@/error/const";
export const transformUnexpectedError = (
error: unknown,
): AppServerErrorMessage => {
return {
...APP_ERROR.SYSTEM.UNRECOVERABLE.EE99,
runtime: {
stack: JSON.stringify(error),
} as RuntimeError,
status: 500,
};
};
ErrorBaundary
例外を変換する箇所
...
componentDidCatch(err: Error, errInfo: ErrorInfo) {
const error = transformBoundaryError(err, errInfo);
outputErrorLog(error); // ロギングする
this.setState(() => {
return {
error,
};
});
}
...
render() {
if (this.state.error) {
return (
<ErrorScreen
error={this.state.error}
onReset={() => {
this.setState(() => {
return {
error: undefined,
};
});
}}
/>
);
}
return this.props.children;
}
エラー変換処理関数
import { APP_ERROR, type AppErrorMessage } from "@/error/const";
export const transformBoundaryError = (
error: Error,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
errInfo?: any,
): AppErrorMessage => {
return {
...APP_ERROR.SYSTEM.UNRECOVERABLE.EE99,
runtime: { cause: error.cause, stack: errInfo },
};
};
unhandledRejectionError
例外を変換する箇所
...
componentDidMount() {
// 非同期処理内で発生したエラー Uncaught (in promise) をキャッチする
window.addEventListener(
"unhandledrejection",
(e: PromiseRejectionEvent) => {
const error = transformUnhandledRejectionError(e.reason);
outputErrorLog(error); // ロギングする
throw error;
},
);
}
componentWillUnmount() {
window.removeEventListener(
"unhandledrejection",
(e: PromiseRejectionEvent) => {
throw transformUnhandledRejectionError(e.reason);
},
);
}
...
エラー変換処理関数
import { APP_ERROR, type AppErrorMessage } from "@/error/const";
export const transformUnhandledRejectionError = (
error: PromiseRejectionEvent,
): AppErrorMessage => {
if (error.reason instanceof PromiseRejectionEvent) {
return APP_ERROR.SYSTEM.UNRECOVERABLE.EE98;
}
return APP_ERROR.SYSTEM.UNRECOVERABLE.EE99;
};
まとめ
サーバーサイドの場合、FWが提供する例外処理機能(Nest.jsでいうFilter)があるケースが多い印象でした。
JavaScript x React.jsで例外処理を体系的にまとめた記事が少なかったので、現場の強強エンジニアに壁打ちをしてもらいながら、例外設計してみた。かなり奥が深かったので勉強になリました。
みなさんの現場では、どんなやり方をしていますか?
この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!フィードバックや改善点があれば、コメント頂けたら嬉しいです!
追記
「エラーを画面に表示させる」を投稿しました!
参考
Reactにおけるエラーの種類と発生パターン
Discussion
APIクライアント系のライブラリ(axios, apollo)内でインターセプトするのはどうか?
→例外の処理は、ユースケースごとに処理を変えたいから。
共通処理化してぐちゃぐちゃになってるプロダクトを結構見かけます🫠