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', id], но кто-то использовал ['products', id], и оба смотрят на разные кэши"
-
"Когда я добавил параметр к этому ключу запроса, существующий кэш остался, и устаревшие данные были показаны"
В React Query ключ запроса — это не просто идентификатор. Это адрес кэша и единица аннулирования. Здесь "аннулирование" означает, что мы сообщаем React Query: "Эти данные больше не актуальны, так что если они понадобятся в следующий раз, забери их снова с сервера".
Если вызвать queryClient.invalidateQueries({ queryKey: ['product'] }), все запросы, ключи которых начинаются с массива ключей ['product'], будут аннулированы сразу. Например, ['product', 'list'] и ['product', '123'] тоже относятся к этому. Это называется "совпадение префиксов", проще говоря, это как структура папок, где верхний ключ включает все нижние ключи.
Если эта структура будет определена по разному в каждом файле, позже никто не сможет понять всю структуру. В этой статье мы представляем паттерн Query Key Factory, который наша команда применяла для решения этой проблемы.
2. От антипаттерна к Query Key Factory
Антипаттерн: что происходит, когда ключи разбросаны
Это самая распространенная проблема. ['products'] и ['product'] — это разные ключи, а ['project-health'] кажется совершенно не связанным с продуктом, но на самом деле это "данные конкретного продукта, которые должны быть аннулированы вместе, когда они изменяются".
-
Каждый раз, когда вы пишете код для аннулирования, вам нужно помнить и перечислить связанные ключи.
-
Если происходит опечатка в ключе, не возникает ошибки времени выполнения, что затрудняет отладку.
-
Когда параметры изменяются или структура ключей изменяется, вам нужно найти и исправить все места использования.
Решение: Фабрика ключей по доменам
Решение заключается в централизованном управлении запросом ключей по объектам (фабрикам) в зависимости от домена.
// 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,
};
Ключевые правила паттерна состоят из двух частей. Во-первых, all() возвращает общий корень. Все под-ключи объединены перед all() (...spread), поэтому можно один раз аннулировать весь этот домен с помощью invalidateQueries({ queryKey: productCmKeys.all() }). Во-вторых, каждая функция принимает параметры (условия) и возвращает готовый ключ. React Query преобразует ключи во внутренние стабильные хеш-значения для сравнения, поэтому { productId: '123' } и { productId: '456' } автоматически отделяются друг от друга в различных кэша.
3. queryOptions / useQueries / invalidateQueries в комбинации
queryOptions: Сбор всех настроек запросов в одном месте
Фабрика ключей запросов становится еще более мощной, когда используется вместе с помощником queryOptions (утилита, добавленная в TanStack Query v5). Если объединить ключ запроса и функцию извлечения данных (queryFn) в одну функцию, можно повторно использовать одинаковые настройки как в хуках, так и в других случаях.
// 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, то можно использовать те же настройки даже в ситуациях предзагрузки данных на сервере или перед переходом на экран. Объект, который возвращает queryOptions, передается как в useQuery, так и в prefetchQuery.
useQueries: отправка нескольких запросов одновременно
useQuery извлекает только одни данные за раз. Однако, когда нужно одновременно получить детали нескольких проектов, используется useQueries. Поскольку findAgileProjectQuery - это функция, возвращающая настройки, можно использовать map для создания аккуратно оформленного списка запросов, проходя по массиву projectIds. Каждый проект имеет уникальный ключ, поэтому кэш управляется независимо, и аннулирование одного не повлияет на остальные.
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 выполняет роль, содержащую всю кэш-структуру данного домена. При добавлении нового запроса просто добавьте одну ключевую функцию сюда и ссылайтесь на этот ключ в *.query.ts.
В заключение
Если кратко подытожить паттерн Query Key Factory, то это создание функции для ключа запроса, которая управляется в одном месте и непосредственно выражает кэш-уровень в коде.
Сначала может возникнуть мысль: "Зачем так сложно?" Я тоже так думал. Но с увеличением домена и числа членов команды эффект явно проявляется. Если ключи разбросаны по строкам в разных местах, в дальнейшем становится все сложнее понять структуру кэша, а код аннулирования превращается в тревожную ручную работу с вопросом "Не забыли ли мы что-то?". Управление через фабрику позволяет, открыв один файл *.keys.ts, увидеть всю структуру одним глазом.
walrus