معماری Cache در TanStack Query برای پروژههای React
مشکل اصلی در پروژههای بزرگ معمولاً خود TanStack Query نیست؛ مشکل این است که بدون قرارداد مشخص، هر Feature برای queryKey، staleTime، invalidation و prefetch تصمیم متفاوتی میگیرد. نتیجه میتواند refetch بیدلیل، cacheهای موازی برای یک داده و mutationهایی باشد که نمیدانند دقیقاً چه چیزی را invalidate کنند.
هدف این مقاله ساختن یک قرارداد ساده برای server state است؛ نه ایجاد یک لایه wrapper سنگین روی TanStack Query.
1. اول مرز Server State را روشن کنید
TanStack Query برای دادهای مناسب است که منبع حقیقت آن بیرون از مرورگر است:
API / Server
↓
TanStack Query Cache
↓
React UI
مواردی مثل وضعیت باز یا بسته بودن Modal، step فعلی یک wizard یا draft محلی فرم معمولاً server state نیستند.
اگر یک داده از API میآید و توسط چند بخش رابط کاربری مصرف میشود، قبل از بردن آن به global client store بررسی کنید که آیا همان Query Cache کافی نیست.
2. Query Key را بخشی از معماری Feature بدانید
TanStack Query cache را بر اساس queryKey مدیریت میکند. Key باید داده را بهصورت یکتا توصیف کند و هر متغیری که queryFn به آن وابسته است باید در key حضور داشته باشد.
برای یک Feature سفارش:
export const orderKeys = {
all: ["orders"] as const,
lists: () => [...orderKeys.all, "list"] as const,
list: (filters: OrderFilters) =>
[...orderKeys.lists(), filters] as const,
details: () => [...orderKeys.all, "detail"] as const,
detail: (id: string) =>
[...orderKeys.details(), id] as const,
}
این ساختار دو مزیت دارد:
- invalidation از عمومی به اختصاصی قابل پیشبینی میشود؛
- keyها کنار Feature مالک داده باقی میمانند.
لازم نیست برای هر Query یک «Query Key Factory Framework» داخلی بسازید. یک object کوچک و قابلخواندن معمولاً کافی است.
3. queryOptions را برای هممکانکردن قرارداد Query استفاده کنید
در TypeScript، queryOptions راه خوبی است تا queryKey و queryFn را کنار هم نگه دارید:
import { queryOptions } from "@tanstack/react-query"
export const orderOptions = {
detail: (id: string) =>
queryOptions({
queryKey: orderKeys.detail(id),
queryFn: () => getOrder(id),
staleTime: 30_000,
}),
}
سپس همان قرارداد را در UI، prefetch یا cache update استفاده کنید:
const query = useQuery(orderOptions.detail(orderId))
این الگو بهتر از wrapper عمومی مثل useAppQuery() است که API اصلی کتابخانه را پنهان کند و تعداد زیادی option را دوباره expose کند.
4. staleTime را بر اساس ماهیت داده تنظیم کنید
در TanStack Query داده cacheشده بهصورت پیشفرض stale در نظر گرفته میشود و queryهای stale در موقعیتهایی مثل mount، focus یا reconnect میتوانند دوباره fetch شوند.
پس staleTime باید پاسخ یک سؤال باشد:
این داده برای چه مدت میتواند بدون درخواست جدید، تازه محسوب شود؟
مثلاً:
queryOptions({
queryKey: ["countries"],
queryFn: getCountries,
staleTime: 30 * 60 * 1000,
})
اما برای وضعیت سفارش در حال پردازش:
queryOptions({
queryKey: ["orders", orderId],
queryFn: () => getOrder(orderId),
staleTime: 10_000,
})
یک staleTime واحد برای کل پروژه معمولاً تصمیم خوبی نیست.
5. بعد از Mutation دقیق invalidate کنید
بعد از mutation، سادهترین راه اغلب invalidation هدفمند است:
const mutation = useMutation({
mutationFn: updateOrder,
onSuccess: async (_, variables) => {
await queryClient.invalidateQueries({
queryKey: orderKeys.detail(variables.id),
})
await queryClient.invalidateQueries({
queryKey: orderKeys.lists(),
})
},
})
invalidateQueries queryهای matchشده را stale میکند و queryهای active میتوانند در پسزمینه refetch شوند.
اشتباه رایج:
queryClient.invalidateQueries()
این کار گاهی برای ابزارهای داخلی یا reset کلی قابل قبول است، اما اگر برای هر mutation انجام شود dependency واقعی بین mutation و cache را پنهان میکند.
6. همیشه لازم نیست بعد از Mutation refetch کنید
اگر پاسخ mutation همان entity بهروزشده را برمیگرداند، میتوانید cache مشخص را مستقیم update کنید:
onSuccess: (updatedOrder) => {
queryClient.setQueryData(
orderKeys.detail(updatedOrder.id),
updatedOrder,
)
}
برای listهایی که sorting، pagination یا filter پیچیده دارند، invalidation معمولاً از patch دستی امنتر است.
قاعده عملی:
Entity response is authoritative
→ setQueryData
Related collections may have changed
→ invalidateQueries
7. Prefetch را نزدیک Navigation Intent قرار دهید
Prefetch زمانی ارزش دارد که احتمال استفاده از داده بالا باشد.
مثلاً هنگام hover یا قبل از navigation:
await queryClient.prefetchQuery(
orderOptions.detail(orderId),
)
اما prefetch همه چیز در startup باعث میشود cache به یک سیستم eager-fetch تبدیل شود.
بهتر است prefetch را به intent کاربر، route loader یا مسیرهای پرتکرار وصل کنید.
8. Query Cache را با Global Store کپی نکنید
این anti-pattern رایج است:
API
↓
TanStack Query
↓
Redux/Zustand
↓
Component
اگر Redux/Zustand فقط یک کپی از پاسخ Query نگه میدارد، دو source of truth ایجاد میشود.
Global client state را برای state واقعاً client-owned نگه دارید؛ server state را تا زمانی که نیاز مشخصی وجود ندارد در Query Cache نگه دارید.
9. Query را نزدیک Feature مالک داده قرار دهید
ساختار ساده:
features/orders/
├── api/
│ ├── get-order.ts
│ └── update-order.ts
├── queries/
│ ├── order-keys.ts
│ └── order-options.ts
└── components/
└── order-details.tsx
این ساختار یعنی Feature مالک قرارداد cache خودش است، ولی QueryClient و provider همچنان infrastructure سراسری برنامه هستند.
10. چند anti-pattern که باید در Review پیدا کنید
Keyهای رشتهای پراکنده
["order", id]
["orders", id]
["order-detail", id]
سه convention برای یک entity یعنی invalidation سختتر.
staleTime: Infinity بدون دلیل
این مقدار ممکن است مناسب دادهای باشد که فقط با invalidation دستی تغییر میکند، اما نباید راه فرار از فهمیدن lifecycle داده باشد.
wrapper عمومی برای همه Queryها
اگر wrapper فقط options کتابخانه را دوباره تعریف میکند، coupling و migration cost اضافه میکند.
invalidation خیلی گسترده
هر mutation باید dependency cache خودش را مشخص کند.
mirror کردن server state در store دیگر
این کار synchronization را به مسئله جدیدی تبدیل میکند.
یک قرارداد عملی برای تیم
برای هر Feature این سؤالها را پاسخ دهید:
1. Owner داده کدام Feature است؟
2. Query key hierarchy چیست؟
3. متغیرهای queryFn داخل key هستند؟
4. staleTime با ماهیت داده سازگار است؟
5. mutation کدام keyها را تغییر میدهد؟
6. setQueryData بهتر است یا invalidation؟
7. آیا prefetch واقعاً با navigation intent مرتبط است؟
8. آیا داده بدون دلیل در Zustand/Redux کپی شده؟
اگر این قرارداد روشن باشد، بیشتر مشکلات cache قبل از تبدیلشدن به bug حذف میشوند.
جمعبندی
معماری خوب TanStack Query یعنی استفاده بیشتر از abstraction نیست. برعکس، معمولاً یعنی:
Feature ownership
↓
Stable query keys
↓
Co-located query options
↓
Intentional staleTime
↓
Targeted invalidation
↓
No duplicated server state
وقتی این مرزها روشن باشند، Query Cache به بخشی قابلپیشبینی از معماری React تبدیل میشود، نه مجموعهای از fetchهای پراکنده.
منابع
- https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
- https://tanstack.com/query/latest/docs/framework/react/guides/query-options
- https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults
- https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation
- https://tanstack.com/query/latest/docs/framework/react/guides/prefetching