React Query Key Factoryパターンを使ってみた

React Query Key Factoryパターンを使ってみた

1. はじめに:クエリキーが重要な理由

React Query(TanStack Query)を初めて導入すると、ほとんどの場合はこのように始めます。

useQuery({ queryKey: ['product-list'], queryFn: fetchProducts });
useQuery({ queryKey: ['product', productId], queryFn: () => fetchProduct(productId) });
useQuery({ queryKey: ['project-health', productId], queryFn: () => fetchHealth(productId) });

コンポーネントが数個しかないうちは問題ありません。しかし、ドメインが増え、チームメンバーが増え、機能が積み重なると、ある時点で次のような状況に直面します。

  • 「product関連のキャッシュをすべて削除したいけど、キーはどこにあるんだ?」

  • 「['product', id]と書いたのに、誰かが['products', id]と書いていて、2つが別のキャッシュを参照していた」

  • 「このクエリキーにパラメータを追加したら、既存のキャッシュがそのまま残って stale データが表示された」

React Queryにおいて、クエリキーは単なる識別子ではありません。キャッシュのアドレスであり、無効化の単位です。ここでいう「無効化」とは、React Queryに「このデータはもう古いから、次に必要になったらサーバーから再取得して」と知らせることです。

queryClient.invalidateQueries({ queryKey: ['product'] })を呼び出すと、キー配列の先頭が['product']で始まるすべてのクエリが一度に無効化されます。たとえば、['product', 'list']も['product', '123']もすべて対象です。これを「prefix matching(前方一致)」といいます。簡単に言えば、フォルダー構造のように、上位キーが下位キーをすべて含む仕組みです。

この構造を各ファイルにばらばらに定義してしまうと、後になって誰も全体の構造を把握できなくなります。この記事では、私たちのチームがこの問題を解決するために導入したQuery Key Factoryパターンを紹介します。

2. アンチパターンからQuery Key Factoryへ

アンチパターン:キーを分散させると起きること

最もよくある問題のケースです。['products']と['product']は異なるキーですし、['project-health']はproductとはまったく関係がないように見えますが、実際には「特定のproductのデータが変更されたときに、一緒に無効化すべき」ものです。

  • 無効化コードを書くたびに、関連するキーを自分で覚えて列挙しなければなりません

  • キーをタイプミスしてもランタイムエラーが発生しないため、デバッグが困難です

  • パラメータが変わったりキー構造が変更されたりすると、すべての使用箇所を探して修正する必要があります

解決策:ドメインごとのKey Factory

解決策は、クエリキーをドメインごとのオブジェクト(factory)で一元管理することです。

// product.cm.keys.ts
export const productCmKeys = {
  all: () => ['product', 'cm'] as const,
  lookups: (params: { stageId?: string }) =>
    [...productCmKeys.all(), 'findProductLookups', params] as const,
  projectHealthSummaries: (params: { productId?: string }) =>
    [...productCmKeys.all(), 'findProjectHealthSummaries', params] as const,
  releaseTimeline: (params: { productId?: string }) =>
    [...productCmKeys.all(), 'findReleaseTimeline', params] as const,
  productNode: (params: { productId?: string }) =>
    [...productCmKeys.all(), 'findProductNode', params] as const,
};

このパターンの基本ルールは2つあります。1つ目は、all()が共通ルート(ルートキー)を返すことです。すべての下位キーはall()を先頭に展開して(...スプレッド)付けるため、invalidateQueries({ queryKey: productCmKeys.all() })でこのドメイン全体を一度に無効化できます。2つ目は、各関数がパラメータ(条件値)を受け取り、完成したキーを返すことです。React Queryはキーを内部的に安定したハッシュ値へ変換して比較するため、{ productId: '123' }と{ productId: '456' }は自動的に異なるキャッシュとして区別されます。

3. queryOptions / useQueries / invalidateQueriesとの組み合わせ

queryOptions:クエリ設定を1か所に集約する

Query Key Factoryは、queryOptionsヘルパー(TanStack Query v5で追加されたユーティリティ関数)と組み合わせると、さらに強力になります。クエリキーとfetching関数(queryFn)を1つの関数にまとめておくと、フックで使う場合でも、それ以外の場面で使う場合でも、同じ設定を再利用できます。

// findProjectHealthSummaries.query.ts
export const findProjectHealthSummariesQuery = (params: Params) =>
  queryOptions<FetchResponse<ProjectHealthRdo[]>>({
    queryKey: productCmKeys.projectHealthSummaries(params),
    queryFn: () => ProductProjSeekApi.findProjectHealthSummaries(params).then((res) => res.data),
});
// useFindProjectHealthSummaries.ts
export const useFindProjectHealthSummaries = (params: Params = {}) => {
  const { data, isLoading } = useQuery(findProjectHealthSummariesQuery(params));
  return { projectHealthSummaries: data?.fetchResult ?? ([] as ProjectHealthRdo[]), isLoading };
};
// 사용 예시
await queryClient.prefetchQuery(findProjectHealthSummariesQuery({ productId }));

Reactでは、コンポーネントの外や通常の関数の中でフックを呼び出すことはできません。しかし、queryOptionsで設定を分離しておけば、サーバーサイドレンダリングや、画面に入る前にデータを先読みするprefetchの場面でも、同じ設定をそのまま使えます。queryOptionsが返すオブジェクトは、useQueryにもprefetchQueryにもそのまま渡せます。

useQueries:複数のクエリを同時に実行する

useQueryは一度に1つのデータだけを取得します。しかし、複数のプロジェクトの詳細情報を同時に取得する必要がある場合は、useQueriesを使います。findAgileProjectQueryが設定を返す関数なので、projectIds配列をmapで処理して、クエリ一覧をすっきり作成できます。各プロジェクトは固有のキーを持つため、キャッシュも独立して管理され、1つが無効化されても残りには影響しません。

export const useFindAgileProjectDetails = (projectIds: string[]) => {
  const results = useQueries({
    queries: projectIds.map((id) => findAgileProjectQuery({ projectId: id })),
  });
  const agileProjectDetails: AgileProjectDetailRdo[] = results
    .map((r) => r.data?.fetchResult)
    .filter((d): d is AgileProjectDetailRdo => d != null);
  const isLoading = results.some((r) => r.isLoading);
  return { agileProjectDetails, isLoading };
};

invalidateQueries:範囲を選んでキャッシュを削除する

このパターンで最も実用的な部分です。無効化する範囲を「ドメイン全体」→「特定のクエリ」→「特定のパラメータのクエリ」と段階的に絞り込んで選択できます。どのキーがあるかを覚える必要はありません。コードでproductCmKeys.まで入力するだけで、IDEの自動補完によって、そのドメインに属するクエリ一覧がすべて表示されます。

// 특정 쿼리 하나만 무효화
queryClient.invalidateQueries({
  queryKey: productCmKeys.projectHealthSummaries({ productId }),
});
 
// product 도메인 전체 무효화
queryClient.invalidateQueries({ queryKey: productCmKeys.all() });
 
// mutation 성공 후 여러 도메인 무효화
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: productCmKeys.all() });
  queryClient.invalidateQueries({ queryKey: teamCmKeys.all() });
},

4. フォルダー構造とまとめ

フォルダー構造:ドメインごとに分離

このパターンを適用すると、自然に次のようなフォルダー構造になります。

feature/
├── product/
│   └── cm/
│       ├── queries/
│       │   ├── product.cm.keys.ts          ← 키 factory
│       │   └── findProductLookups.query.ts  ← queryOptions 함수
│       ├── hooks/
│       │   └── useFindProductLookups.ts     ← 훅
│       └── index.ts
├── team/
└── staffing/
 ...

*.cm.keys.tsが、そのドメインのキャッシュ構造全体を記したドキュメントの役割を果たします。新しいクエリを追加するときは、ここにキー関数を1つ追加し、*.query.tsでそのキーを参照します。

まとめ

Query Key Factoryパターンを一言でまとめると、クエリキーを関数にして1か所で管理し、キャッシュ階層をコードで直接表現することです。

最初は「わざわざここまでやる必要があるの?」と思うかもしれません。私もそうでした。しかし、ドメインが増えるほど、チームメンバーが増えるほど、その効果ははっきり現れます。キーを文字列としてあちこちに分散させると、後になってキャッシュ構造を把握するのがどんどん難しくなり、無効化コードも「これで漏れはないか?」という不安な手作業になります。ファクトリーで管理すれば、*.keys.tsファイルを1つ開くだけで、全体の構造を一目で確認できます。

walrus

Site footer