Using the React Query Key Factory Pattern

Using the React Query Key Factory Pattern

1. Introduction: Why Are Query Keys Important?

Most people start with React Query (TanStack Query) like this.

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

When there are only a few components, it's not a problem. However, as the domain grows, team members increase, and features accumulate, we eventually encounter this situation.

  • "I need to clear all the caches related to product, but where are the keys?"

  • "I wrote ['product', id], but someone wrote ['products', id], and they were looking at different caches"

  • "I added parameters to this query key, and the existing cache remained, exposing stale data"

In React Query, a query key is not just a simple identifier. It is the address of the cache and the unit of invalidation. Here, "invalidation" means telling React Query, "This data is now outdated, so fetch it again from the server when needed."

When you call queryClient.invalidateQueries({ queryKey: ['product'] }), all queries that start with the key array ['product'] are invalidated at once. For example, both ['product', 'list'] and ['product', '123'] apply. This is called "prefix matching", which simply means that a higher-level key includes all lower-level keys, like a folder structure.

If this structure is defined differently in each file, later nobody will be able to understand the overall structure. This article introduces the Query Key Factory pattern that our team applied to solve this problem.

2. From Anti-Pattern to Query Key Factory

Anti-Pattern: What Happens When Keys Are Scattered

This is the most common problem case. ['products'] and ['product'] are different keys, and ['project-health'] seems entirely unrelated to product, but in reality, they are things that "should be invalidated together when the data of a specific product changes."

  • Every time I write invalidation code, I have to remember and list the related keys directly.

  • Debugging becomes difficult due to the lack of runtime errors even when a key typo occurs.

  • When parameters change or the key structure is modified, all usages must be found and updated.

Solution: Domain-specific Key Factory

The solution is to centrally manage query keys as domain-specific objects (factories).

// 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,
};

The core rules of the pattern are twofold. First, all() returns a common root. Since all sub-keys are spread out before being attached to all(), you can invalidate the entire domain at once with invalidateQueries({ queryKey: productCmKeys.all() }). Second, each function takes parameters (condition values) and returns a complete key. Because React Query internally converts keys into stable hash values for comparison, { productId: '123' } and { productId: '456' } are automatically distinguished into different caches.

3. Combining queryOptions / useQueries / invalidateQueries

queryOptions: centralizing query settings in one place

The Query Key Factory becomes even more powerful when used with the queryOptions helper (a utility function added in TanStack Query v5). By bundling the query key and fetching function (queryFn) into a single function, you can reuse the same settings in hooks and other situations.

// 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 }));

In React, hooks cannot be called outside of components or regular functions. However, if you separate settings with queryOptions, you can use the same settings in server-side rendering or prefetch situations where data is fetched in advance before entering the screen. The object returned by queryOptions is passed directly to both useQuery and prefetchQuery.

useQueries: firing multiple queries simultaneously

useQuery fetches only one piece of data at a time. However, when you need to fetch detailed information for multiple projects simultaneously, you use useQueries. Since findAgileProjectQuery is a function that returns settings, you can neatly create a list of queries by mapping over the projectIds array. Each project has a unique key, so the caches are managed independently, and if one is invalidated, the others remain unaffected.

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: selectively clearing caches

This is the most practical part of the pattern. You can narrow down the invalidation scope step by step from "entire domain" → "specific query" → "query with specific parameters". There's no need to memorize which keys are available; by simply entering productCmKeys., the IDE autocomplete will display the entire query list for that domain.

// 특정 쿼리 하나만 무효화
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. Folder Structure and Conclusion

Folder Structure: Separation by Domain

By applying this pattern, a natural folder structure like the following emerges.

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

The *.cm.keys.ts serves as a documentation containing the entire cache structure for that domain. When adding a new query, you add one key function here and reference that key in *.query.ts.

In conclusion

To summarize the Query Key Factory pattern in one line, it involves creating query keys as functions to manage them in one place and express the cache layer directly in code.

At first, you might think, 'Is it really necessary to go this far?' I felt the same way. However, as the domain expands and the team grows, the benefits become clearly apparent. When keys are scattered as strings, it becomes increasingly difficult to understand the cache structure later on, and the invalidation code turns into a nervous manual task of 'Did I miss anything?' With a factory management approach, you can see the entire structure at a glance by opening just one *.keys.ts file.

walrus

Site footer