React SPAでWeb APIを扱うときのA Clean Architecture
はじめに
ReactでWeb APIアクセスを実装するときにどのようにしていますか?直接コンポーネントに(useEffectなどに)fetchするコードを書いたり(・・・することはあまり多くなさそう)、libディレクトリに fetchPosts のような関数を用意してAPIを呼び出したり、hookとしてusePostsを用意ししてその中でAPIを呼ぶコードを書いていたりするでしょうか。
この記事ではhookの下に2層(UseCase層、Interactor層)追加した、Clean Architecture的なAPIアクセスを実現する方法について述べます。
この記事ではある程度の数以上のREST APIを扱うシステムで、継続的に開発・保守を行なっていくシステムを想定しています。扱うAPIの数が多くなかったり、システム自体のライフサイクルが短かったりする場合は、最初に挙げたlibディレクトリ以下に実装する方法などでも十分かもしれません。一定以上の規模であればコードベースの保守性やテスタビリティが重要になってきます。そこで本記事で提案する構成を用いることで、保守性やテスト性を高めることができます。
全体の構成
(上の図を見てなるほど、と思う人はこの章はいらないかもしれません。あんまりポイントがわからないという人は、どこに矢印があるかではなく、どこに矢印がないかに着目すると良いかもしれません。)
今回の構成では、Web APIを呼び出すためのコードをhooks
useCases
, interactors
の3箇所に分けて実装します。
hooks
はコンポーネントから呼び出されるReact hookの層です。 ここでTanstack Query(またはSWR)を用います。useCases
のインタフェースを呼び出してhookを実装します。直接 interactor
を呼ぶことはしません。
useCases
はユースケース定義の層です。interfaceとして定義し、実際の振る舞いは記述しません。
interactors
はユースケースの実装になります。実際にaxios(やfetch)を呼び出しAPIリクエストを行います。
実際の例
架空の支払い情報管理APIを実装してコード例を見ていきます。以下のようなオブジェクトのCRUDを行うAPIがあって、それに対応するReact hookを実装することが最終目的です。
const paymentId = z.string();
const paymentBody = z.object(...);
const payment = paymentBody.extend({id: paymentId});
type PaymentId = z.infer<typeof paymentId>;
type PaymentBody = z.infer<typeof paymentBody>;
type Payment = z.infer<typeof payment>;
依存関係の最初に一番内側にある useCases
層から見ていきます。
type GetPayment = (id: PaymentId) => Promise<Payment>;
type UpdatePayment = (id: PaymentId, body: PaymentBody) => Promise<void>;
type DeletePayment = (id: PaymentId) => Promise<void>;
type CreatePayment = (productId: ProductId, body: PaymentBody) => Promise<PaymentId>
type ListPaymentIds = (productId: ProductId) => Promise<PaymentId[]>
interface PaymentUseCase {
get: GetPayment;
update: UpdatePayment;
physicalDelete: DeletePayment;
create: CreatePayment;
list: ListPaymentIds;
}
この PaymentUseCase
を使うと、hookの実装はこのようになります。
type Dependencies = {
paymentUseCase? : PaymentUseCase;
}
const usePayment = ({paymentUseCase}: Dependencies, id: PaymentId) => {
const queryClient = useQueryClient();
const { data,error,isLoading } = useQuery({
queryKey:['payment', id],
queryFn: () => paymentUseCase?.get(id)}
);
const update = useMutation({
mutationFn: (body: PaymentBody) => {
if(!paymentUseCase){
throw "paymentUseCase is not ready";
}
return paymentUseCase.update(id, body);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey:['payment', id]});
}
});
// 同様に実装します
// const physicalDelete = {...};
return {data, error, isLoading, update, physicalDelete};
}
// 同様に実装します
// const usePayments = ({paymentUseCase}: Dependencies, productId: ProductId) => {}
// CRUDのCreateとIndex(List)はこちらに実装
実際にリクエストするinteractorは以下のようになります。(axiosを呼び出しているだけ)
class PaymentInteractor implements PaymentUseCase{
constructor(token: string){
// アクセストークンを設定したりします
}
get: GetPayment = async (id: PaymentId) => {
const res = await axios.get(`http://localhost:3001/payments/${id}`);
return res.data;
};
// 同様に実装します
// update: UpdatePayment = (...) => {...};
// physicalDelete: DeletePayment = (...) => {...};
// create: CreatePayment = (...) => {...};
// list: ListPaymentIds = (...) => {...};
}
コンポーネントから使うためには、 PaymentInteractor
をDIしてあげる必要があります。そのために補助のhookとして、 useInteractors
フックを導入します。
const useInteractors = (token: string) => {
const paymentUseCase = useMemo(() => new PaymentInteractor(token), [token]);
const userUseCase = ...
const accountUseCase = ...
const productUseCase = ...
return {
paymentUseCase,
...
};
}
上記の useInteactors
をコンポーネントから使う例はこのようになります。
type Props = {
id: PaymentId;
}
const PaymentUpdatePage = ({id}: Props) => {
const {token} = useAuth();
const depenencies = useInteractors(token);
const {data, update} = usePayment(depenencies, id);
if(!data){
return <LoadingCircle />
}
return <PaymentUpdateForm initialValue={data} onUpdateClick={update}/>
}
推しポイント
だいぶボイラプレートなコードが増えたようにも見えますが、筆者はこの構造には一定の効果があると考えています。特に推しているポイントを挙げます。
実装の入れ替えの容易性
Interactorを入れ替えることで、モックと入れ替えることができます。ローカルでCRUDするモックを用意してあげれば、実際に画面での動作を確認しながら実装することができます。
useInteractorで環境変数で分岐するようにし、フロントエンドでの総合テスト時にはモックを使い、本番環境やE2Eの時はAPIにアクセスするようにする、などの制御も可能です。
class LocalPaymentInteractor implements PaymentUseCase{
db: Record<PaymentId, Payment>;
productIdToPaymentIds: Record<ProductId, PaymentId[]>;
constructor(){
this.db = {}
this.productIdToPaymentIds = {};
}
get: GetPayment = async (id: PaymentId) => {
if(!this.db[id]){
throw "Not found";
}
return this.db[id];
};
create: CreatePayment = async (productId: ProductId, body: PaymentBody) => {
const id = genUUID();
this.productIdToPaymentIds[productId] =[...this.productIdToPaymentIds[productId], id];
this.db[id] = {body, id};
return id;
}
// 同様に実装します
// update: UpdatePayment = (...) => {...};
// physicalDelete: DeletePayment = (...) => {...};
// list: ListPaymentIds = (...) => {...};
}
hookが単純な形で済む
hookの中では、単純なTanstack Query(or SWR)の呼び出しにしています。それによりhookのテストの重要性を下げることができます。Hookのテストは経験上チームで維持するのは難しいと感じています。
hookには useCases
層の呼び出し以外書かないルールにすることで、振る舞いのテストは interactor
に対して行い、hookについてはキャッシュの更新サイクルが正しく行えているかのみを焦点とすることができます。
テスト対象の明確化
useCases
,interactors
層を設けなかった場合でも、どこかのレイヤでSpyしてテストを動かすことはできますが、実際に他の(テスト実装経験の多くない)メンバにテストテストを設計・実装してもらうと、正しくテストしたいことことを判定するテストになっていないことも多く、ちゃんとテストダブルを使ったテストを書くのは難しいなと感じています。(このあたりはAIの普及などで変わってくるのかもしれませんが…)
この記事で提案している構成ではinteractors
の各メソッドの役割は明確なので、それぞれをテストすることができます。また、Spyしたオブジェクトではなく実際の振る舞いに近い interactor
を用意してあげることができ、利用するコンポーネント側のテストも行いやすくなります。
Scaffoldingツールによる自動生成が可能
仮実装はリソース名の置換で実現できるためhook
, useCase
とローカルバージョンの interactor
をscaffoldingツールを使って自動生成することができます。筆者はplopを使ってnpmコマンドで一発でファイルが生成されるようにしています。これによりチームの他の人が新しいREST APIを作成しようとした時でも、アーキテクチャを維持することができます。
終わりに
書きたいことが発散して、要点をまとめきれませんでした。深掘りしたいトピックなどあればコメントいただけると嬉しいです。最後にChatGPT先生にこの記事を批評してもらった内容をコピペして終わります。テストやエラーハンドリングについてはだいぶ端折ってしまったので詳しく知りたいなどあれば追記します。
ChatGPT先生曰く
この記事で提案されているアーキテクチャは、大規模なプロジェクトや複雑なシステムでのAPIアクセスの実装に向いていると言えます。しかし、小規模なプロジェクトや短期的な開発には過剰である可能性があり、シンプルなアーキテクチャの方が適している場合があります。また、実装の抽象化が進みすぎることでコードの冗長性が増し、維持が難しくなるリスクがある点に注意が必要です。
→ 確かに、その通りだと思います。オーバーエンジニアリングには注意が必要ですね。
Discussion