🧩

React.js 例外処理の設計2 (画面に表示する)

2024/07/30に公開

概要

下記のような、場当たり的な例外処理をやめたいので、例外処理の設計を考えてみました。
この記事ではReact.jsを対象としています。
※ちなみ「例外」と「エラー」を同じ意味で使っています。

https://zenn.dev/rsugi/articles/7778f06b30f728

で記載した内容の続きで、「エラーを画面に表示させる」までをまとめます。

対象読者

  • JavaScriptのフレームワークを使って開発している方
  • 例外処理を雑に処理している方
  • エラーの表示方法に困っている方

いいね!してね

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

それでは以下が本編です。

結論

3層アーキテクチャにして、ユースケース内で例外の表示処理を呼ぶ。

説明すること

  • 例外処理の基本方針
  • 例外を画面に表示させる
  • 3層アーキテクチャ構成(具体例)

例外処理の基本方針

例外の種類とエラー補足方法の対応関係(再掲)

https://zenn.dev/rsugi/articles/7778f06b30f728#例外の種類とエラー補足方法の対応関係

例外を画面に表示させる

例外の表示パターン

1 全体 ErrorBaundaryで表示する (SPAで画面全体が切り替わる)
2 個別 ErrorBaundaryで表示する (SPAで画面の特定部分が切り替わる)
3 現在の画面の一部に表示される (例: エラー用のヘッダー)
4 現在の画面内に通知形式で表示される (例: トースト)
5 現在の画面内のフォームに反映させる (例: フォームバリデーションエラー)

この5つのパターンのどれにするか、APIリクエスト時にオプションとして選択できるようにしたいと思います!

APIエラーレスポンス

よくある「Viewから直接APIリクエストする」場合、状態ごとの値が多すぎて複雑になりがちです。
それを処理するためにViewファイルが肥大化するか、場当たり的なヘルパー関数が生成されていきます。

初期値 取得中 成功時 失敗時
data undefined undefined data null
loading false true false false
error null null null error
typicalGraphQLCall.tsx
const Member: React.FC = () => {
  // 1リクエストだけならまだマシ。リクエストが3つ以上になると変数多すぎてキツい
  const { loading, error, data } = useQuery(GET_MEMBER, {
    variables: { id: '1' } // 取得したいメンバーのIDを指定
  });
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>Member Details</h1>
      <p>ID: {data.member.id}</p>
      <p>Name: {data.member.name}</p>
      <p>Email: {data.member.email}</p>
    </div>
  );
};

で、APIエラーレスポンスはレポジトリ関数内でtry-catchで例外を即時捉えて変換え処理しよう! と書きました。

View → Repository
https://zenn.dev/rsugi/articles/7778f06b30f728#④関数内でtry-catchで捉えて処理する

今回「例外を画面に表示する」を実装するために、ユースケース層を追加します。
例外に値が入っていたら、ユースケース層で別のユースケース層(エラー表示用)を呼び出すようにします。

View -> UseCase(という名のhooks) -> Repository(という名のhooks)

3層アーキテクチャ(具体例)

View

feature/mypage/index.tsx
import type { FC } from "react";

import { useFetchActiveMember } from "@/core/usecases/member/useFetchActiveMember.query";

const IndexTemplate: FC = () => {
	const { data: activeMember, loading } = useFetchActiveMember();

	if (loading) {
		return <div>loading...</div>;
	}

	if (activeMember == null) {
		// メンバーが存在しない場合はリダイレクト
	}

	return <div>ActiveMember: {JSON.stringify(activeMember, null, 2)}</div>;
};

export default IndexTemplate;

UseCase

core/usecases/member/useFetchActiveMember.query.ts
mport { useEffect, useState } from "react";

import type { ActiveMember } from "@/core/domains/member/activeMember";
import { useFindActiveMemberOne } from "@/core/repositories/member/members.repository";
import { outputErrorLog } from "@/error/outputErrorLog";
import { useNotification } from "../../../error/hooks/useNotification";

type InitialActiveMember = undefined;
type ResultActiveMember = ActiveMember;
type ActiveMemberState = InitialActiveMember | ResultActiveMember;
const initialActiveMember: InitialActiveMember = undefined;

type UseCaseLoading<I> = {
	data: I;
	loading: true;
} & Record<string, unknown>;

type UseCaseLoaded<R> = {
	data: R;
	loading: false;
} & Record<string, unknown>;

type UseCase<I, R> = UseCaseLoading<I> | UseCaseLoaded<R>;

export const useFetchActiveMember = (): UseCase<
	InitialActiveMember,
	ResultActiveMember
> => {
	const [activeMember, setActiveMember] =
		useState<ActiveMemberState>(initialActiveMember);
	const [loading, setLoading] = useState<boolean>(false);
	const query = useFindActiveMemberOne();
	const { notify } = useNotification();

	// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
	useEffect(() => {
		(async () => {
			setLoading(true);
			const { data, error } = await query(
				"ff4b01ee-15e9-4e2e-acb3-25a0347af7c1",
			);
			setActiveMember(data);
			if (error) {
				notify({error});; // エラー表示用のhooksを呼び出す
				outputErrorLog(error); // エラーのロギング関数を呼び出す
			}
			setLoading(() => false);
		})();
	}, []);

	return {
		data: activeMember,
		loading,
	} as UseCase<InitialActiveMember, ResultActiveMember>;
};

ちなみに、 Viewファイル側でloading=falseの場合、dataの型が確定するようにしています(UseCaseLoading<I>, UseCaseLoaded<I>)

これをやらないと呼び出し側がオプショナルチェーンだらけになって可読性が下がります
if (loading)より下の行では、dataの型が確定している状態になっています。

Repository

core/repositories/member/members.repository.ts
type FetchActiveMemberReturnType = {
	data: ActiveMember | null;
	error: AppErrorMessage | null;
};
type FindActiveMemberOneType = (
	memberId: string,
) => Promise<FetchActiveMemberReturnType>;

export const useFindActiveMemberOne = (): FindActiveMemberOneType => {
	const [query] = useGetActiveMemberLazyQuery();

	return async (memberId) => {
		try {
			const res = await query({ variables: { memberId } });
			const member = transform(res);
			return { data: member, error: null };
		} catch (error) {
			return { data: null, error: transformError(error) }; // 発生時に、例外を変換しておく。
		}
	};
};

UseCase(エラー表示用)

 error/hooks/useNotification.tsx
import { useEffect, useState } from "react";
import type { AppErrorMessage } from "../const";

type ErrorRenderType = "baundary" | "header" | "toast" | "form";
const defaultErrorRenderType = "toast";

type ErrorNotification = {
	error: AppErrorMessage | null;
	errorRenderType?: ErrorRenderType;
} | null;

export const useNotification = () => {
	const [error, notify] = useState<ErrorNotification>(null);
	// TODO: この関数内でエラー表示形式の分岐、表示処理、表示後のリセット処理を行う(baundaryを呼ぶときはthrow errorすれば良いはず。個別にbaundaryを用意するかは要件次第で!)

	useEffect(() => {
		if (error) {
			// 一旦alertでエラーを表示している。
			window.alert(error);
		}
	}, [error]);

	return {
		notify,
		reset: () => notify(null),
	};
};

3層アーキテクチャまとめ

ユースケース内で例外発生時に適切に処理するため、View側で考慮する変数はdata, loadingのみとなります

定型的なUseCaseを作る手間が増えたけど、ロジックを考える時間が減りました!

初期値 取得中 成功時 失敗時
data undefined undefined data null
loading false true false false

課題点

今回、シンプルなAPI操作 x 1つを例に説明しました。

API操作x2つ以上(GET, POST)をしたい場合、いわゆるService層を追加して、その中でUseCaseを複数呼ぶパターンになるのでしょうか?

そこまで複雑になるならバックエンドを用意した方が良いかもしれませんが。

まとめ

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!フィードバックや改善点があれば、コメント頂けたら嬉しいです!

Discussion