React + Jotaiで依存性注入(DI)したいときの我流レシピ
こちらの交流会でLTしたところ、思いのほか珍しい話のようだったので筆を取りました。
この記事では、状態管理ライブラリのJotaiを使ったReactアプリケーションの開発において、依存性注入(Dependency Injection, DI)をやりたいときのプラクティスについて紹介します。
先に結論を
- 「外部に依存する関数を保持するためのAtom」を定義して、具体的な実装はコンポーネント外部から渡せるようにすることで、「ドメイン層を外部に依存させない」「依存関係の一方向に保つ」を両立できる
- これによってドメイン層をクリーンに保つことができ、テストしやすさも向上する
- ただし、どのコンポーネントがどの関数を必要とするのかを分かりづらいなどの難点はある
※筆者がJotaiを愛用しているのでJotai前提で書いていますが、React標準のContext APIだけでも同様のことはできると思います
モチベーション
なぜこのあと紹介するようなまどろっこしいことを考えたのか、理由は大きく2つあります。
1. ドメイン層が外部システムやライブラリに依存しないようにしたい
移り変わりの激しいフロントエンドだからこそ、ドメイン知識や業務ロジックについての記述をクリーンに保つのは変更容易性を長持ちさせるために役立つはず。
ただし、業務ロジックがバックエンドに集中しているシステムであれば、フロントでここまでやる必要はないのかもしれません。
2. インテグレーションテストをやりやすく
外部システムやライブラリへの依存をコンポーネント利用者側から注入することができると、インテグレーションテストの作りやすさが向上します。
やや脱線しますが、このプラクティスに行き着いたのはフロントエンドでテスト駆動開発(TDD)の実践法を模索している最中のことでした。
フロントエンドでのTDDは、「ユーザーから見た時の振る舞いに軸足を置きながら、『動くプロダクトを素早く作ること』と『リファクタリング』を良いバランスで両立できる」のが魅力的で、フロントエンド特有の辛さはたくさんあるものの現在も模索を続けています。
実装例
ここからは具体例をもとに紹介していきます。
今回は「ユーザー情報をDBから取得して、ユーザーページで表示したい」というケースを考えます。さらに、表示上の要件として「ユーザー名は全て大文字で表示する」があるとしましょう。
依存関係を一方向に保ちながら・ドメイン層を外部に依存させずにこの要件を実装するために、以下のように設計しました。
-
1. ドメイン
- User:「ユーザー情報」を表す型
- FindUserFn:「ユーザー情報を取得する関数」を表す型
-
2. コンポーネント
- findUserFnAtom:FindUserFnを満たす関数を保持するAtom
- 初期値の時点では必ず例外を返す関数がセットされる
- コンポーネント利用者側でJotaiのProviderを経由して適切な関数がセットされることを期待する
- userAtom:ユーザーIDごとにユーザー情報を取得・保持するAtomFamily
- findUserFnAtomに依存する
- 表示のためにユーザー名を大文字に変換する
- UserProfileSectionコンポーネント:ユーザー情報の取得・表示を行う
- userAtomに依存。userAtomにユーザーIDを渡して、表示用のプロフィール情報を取得する
- プロフィール情報をUIに流し込む
- findUserFnAtom:FindUserFnを満たす関数を保持するAtom
-
3. コンポーネント利用者(アプリケーションまたはテスト)
- UserProfileSectionコンポーネントを使ってユーザーに画面を提供する
- FindUserFnを満たす関数 findUser をfindUserFnAtomにセットする
- findUserは外部に依存してOK
-
4. 外部
- PrismaなどのORマッパー、FirebaseなどのSDKを読み込むならココ
一応GitHubにもコード例をアップロードしてあるので参考までに(余計なファイルも残ってますが)。
一部抜粋して紹介します。
1. ドメイン
ドメイン層には、アプリケーションが扱うドメイン知識・業務ロジックを外部システムやライブラリに依存することなく記述します。
export type User = { name: string; imageUrl: string; bio: string };
export type FindUserFn = (userId: string) => Promise<User>;
ここでは、
- Userというエンティティがあること
- Userがname, imageUrl, bioという3つの文字列を持つこと
- Userを取得する操作を行えること。その関数はuserIdという文字列を受け取って非同期でUserを返すこと
ということを簡単に表しています。
今回は簡単な型定義しかドメイン層に入れていませんが、具体的な業務ロジックも記述できます。『Domain Modeling Made Functional』でいうワークフローを置くイメージです。
参考までに:
2. コンポーネント
Jotaiを駆使してユーザー情報の取得・保持し、UIに流し込みます。
findUserFnAtomがもっとも重要で要注意な箇所です。
import { Suspense } from "react";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils";
import { FindUserFn } from "../domain/user.ts";
// ユーザー情報を取得する関数を保持するAtom
export const findUserFnAtom = atom<{ fn: FindUserFn }>({
fn: () => {
throw new Error("function must be set"); // 初期値は必ず例外を返す関数。具体的な実装はDIで受け取る
},
});
// ユーザーIDごとにユーザー情報を取得・保持するAtomFamily
const userAtom = atomFamily((userId: string) => {
return atom(async (get) => {
const findUser = get(findUserFnAtom).fn; // findUserFnAtomから関数を取り出す
const user = await findUser(userId); // ユーザーの取得を行う
return {
...user,
name: user.name.toUpperCase(), // ユーザー名は大文字にして表示
};
});
});
type UserProfileProps = { userId: string };
const UserProfile = ({ userId }: UserProfileProps) => {
const user = useAtomValue(userAtom(userId)); // ユーザー情報を取得。取得が終わるまではサスペンドする
return (
<div>
<h1>{user.name}</h1>
<img src={user.imageUrl} />
<p>{user.bio}</p>
</div>
);
};
type UserProfileSectionProps = UserProfileProps;
export const UserProfileSection = ({ userId }: UserProfileSectionProps) => {
return (
<section>
<Suspense fallback="loading...">
<UserProfile userId={userId} />
</Suspense>
</section>
);
};
Jotaiを使うとSuspense前提のデータ取得を簡単に書けるのが便利ですね。
3. コンポーネントの利用者(テスト)
テストコードという形で、上述のUserProfileSectionコンポーネントを実際に使ってみます。
ユーザー取得のための findUser 関数を実装し、findUserFnAtomにセットしてProvider経由でコンポーネントに注入します。
ここでのfindUserは決まったデータを返すだけですが、アプリケーションで実際に利用するときはDBに依存するコードを書けます。
import { describe, expect, test } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import { UserProfileSection, findUserFnAtom } from "./UserProfileSection";
// ユーザー取得のための関数を用意(ここではテスト用のモック)
const findUser = async (_userId: string) => ({
name: "kecy",
imageUrl: "https://kecy.me/image.jpg",
bio: "this is a bio text",
});
describe("UserProfileSection", () => {
test("User name should be displayed in uppercase", async () => {
const store = createStore();
store.set(findUserFnAtom, { fn: findUser }); // 外部から依存性注入
render(
<Provider store={store}>
<UserProfileSection userId="dummy" />
</Provider>
);
await waitFor(() => {
expect(screen.getByText("KECY")).toBeInTheDocument();
});
});
});
おしまい
ここまでで、ReactにおけるDIの実装パターンの一つを示せたと思いますが、
- 初期値として例外を返す関数をセットするのが気持ち悪い・混乱しやすい
- コンポーネント利用者の立場から、どのコンポーネントにどの関数を注入すべきなのかわかりづらい
などの難点はあります。
「こうするともっといいよ」などご意見やフィードバックがありましたらぜひコメントでお寄せください。
Discussion