Type‑Safe Dependency Invalidation & Cache Staleness in React Query (TS)
Introduction
React Query (now called TanStack Query) is a powerful data‑fetching library that abstracts away the tedious bits of loading state, caching, and background refetching. When you add TypeScript into the mix, you get compile‑time guarantees for the shape of the data you receive, but you also inherit a new source of bugs: incorrect query keys or stale cache entries that aren’t refreshed when related data changes.
This article walks through a pattern that makes dependency invalidation type‑safe, and shows how to reason about cache staleness without sprinkling string literals throughout your codebase. We’ll:
- Define a typed query‑key factory that guarantees consistent keys.
- Build a generic invalidation helper that only accepts keys derived from that factory.
- Show how to combine React Query’s stale‑time and refetch‑on‑window‑focus with the helpers to keep UI fresh.
- Demonstrate the approach in a realistic e‑commerce “product‑reviews” feature.
The goal isn’t to replace React Query’s own docs, but to complement them with a TypeScript‑first workflow that scales from a single component to a large codebase.
1. The problem with ad‑hoc query keys
React Query identifies cached data by a query key – an array (or string) that must be exactly the same for a useQuery and for any invalidateQueries call. In a typical codebase you’ll see something like:
// component
const { data } = useQuery(['product', productId], fetchProduct);
// elsewhere
queryClient.invalidateQueries(['product', productId]);
Two things can go wrong:
| Issue | Example | Consequence |
|---|---|---|
| Typo | ['prodcut', productId] |
No cache hit → extra network request |
| Missing param | ['product'] vs ['product', productId] |
Wrong entry invalidated, UI shows stale data |
| Unrelated invalidation | ['product', 42] while you meant ['product', 43] |
Unnecessary refetch, wasted bandwidth |
When you have dozens of endpoints, manually maintaining these arrays becomes error‑prone. TypeScript can help, but we need a single source of truth for the shape of every query key.
2. A typed query‑key factory
We’ll create a utility type that maps each “resource” to a function returning a correctly‑typed key. The key functions are the only place where literal strings live, so a typo will be caught at compile time.
// src/queryKeys.ts
type Primitive = string | number | boolean | null | undefined;
type QueryKey<T extends readonly Primitive[]> = readonly [...T];
export const queryKeys = {
// Simple list endpoint – no params
products: (): QueryKey<['products']> => ['products'],
// Single product – requires an id
product: (productId: number): QueryKey<['product', number]> => [
'product',
productId,
],
// Reviews scoped to a product
productReviews: (productId: number): QueryKey<['product', number, 'reviews']> => [
'product',
productId,
'reviews',
],
// A paginated list that also accepts a filter object
filteredProducts: (filter: { category?: string; priceMin?: number }) =>
['filteredProducts', filter] as const,
} as const;
Why this works
as consttells TypeScript to treat each literal as a readonly tuple, preserving the exact order and type of each element.- The return type
QueryKey<...>forces the tuple to contain only primitives – React Query rejects objects unless they’re deliberately placed in the key (as we do forfilteredProducts). - The
queryKeysobject is immutable; downstream code can only call its methods.
Now any usage of a key must go through queryKeys.*. If you later rename 'product' to 'item', the compiler will flag every call site.
3. Generic invalidation helper
React Query’s invalidateQueries accepts a partial key – you can pass ['product'] to invalidate all product‑related queries. Replicating that flexibility while staying type‑safe requires a bit of generic wizardry.
// src/queryInvalidator.ts
import { QueryClient } from '@tanstack/react-query';
import { queryKeys } from './queryKeys';
type KeyFn = (...args: any[]) => readonly unknown[];
/**
* Build a type‑safe wrapper around queryClient.invalidateQueries.
*
* @param client The QueryClient instance.
* @returns A function that only accepts keys produced by `queryKeys`.
*/
export const makeInvalidator = (client: QueryClient) => {
// overload 1 – exact key (full match)
function invalidate<K extends keyof typeof queryKeys>(
key: ReturnType<typeof queryKeys[K]>,
): Promise<void>;
// overload 2 – partial key (prefix match)
function invalidate<P extends readonly unknown[]>(
partialKey: P,
): Promise<void>;
// implementation
async function invalidate(arg: any): Promise<void> {
// `any` is safe here because the overloads already guarantee correctness.
await client.invalidateQueries({ queryKey: arg, exact: false });
}
return { invalidate };
};
How to use it
import { useQueryClient } from '@tanstack/react-query';
import { makeInvalidator } from '@/queryInvalidator';
import { queryKeys } from '@/queryKeys';
function ReviewForm({ productId }: { productId: number }) {
const queryClient = useQueryClient();
const { invalidate } = makeInvalidator(queryClient);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await postReview(productId, { rating: 5, comment: 'Great!' });
// ✅ Type‑safe: only keys derived from queryKeys are allowed
await invalidate(queryKeys.productReviews(productId));
};
// …
}
If you accidentally try:
await invalidate(['product', 'wrong-id']);
TypeScript will emit an error because ['product', 'wrong-id'] does not match any return type of queryKeys.
4. Staleness strategies that respect type safety
React Query offers two knobs that control when cached data is considered stale:
| Option | Meaning |
|---|---|
staleTime |
Duration (ms) for which data is fresh. While fresh, React Query will not refetch on mount or window focus. |
refetchOnWindowFocus |
Whether to refetch when the browser tab becomes active again. |
Both are set per‑query, but you can centralise defaults with a typed wrapper around useQuery.
// src/useTypedQuery.ts
import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { queryKeys } from './queryKeys';
type KeyOf<T extends keyof typeof queryKeys> = ReturnType<typeof queryKeys[T]>;
export function useTypedQuery<
K extends keyof typeof queryKeys,
TData = unknown,
TError = unknown,
>(
key: KeyOf<K>,
fetcher: () => Promise<TData>,
opts?: UseQueryOptions<TData, TError, TData, typeof key>,
): UseQueryResult<TData, TError> {
// Default staleTime = 5 minutes for list queries, 0 for single-item queries
const defaultStale = key[0] === 'products' || key[0] === 'filteredProducts' ? 300_000 : 0;
return useQuery(key, fetcher, {
staleTime: defaultStale,
refetchOnWindowFocus: true,
...opts,
});
}
Example usage
function ProductDetail({ productId }: { productId: number }) {
const { data, isLoading } = useTypedQuery(
queryKeys.product(productId),
() => api.getProduct(productId),
);
// the hook already knows the key shape, so no accidental mismatch.
}
The wrapper makes it impossible to pass a mismatched key because key is derived from queryKeys. It also gives you a place to enforce policy (e.g., all list queries get a long staleTime).
5. Real‑world scenario: product reviews
Imagine an e‑commerce site where users can:
- View a product’s details (
product/:id). - See a paginated list of reviews (
product/:id/reviews). - Add a new review via a modal form.
5.1 Fetching data
// src/components/ProductPage.tsx
import { useTypedQuery } from '@/useTypedQuery';
import { queryKeys } from '@/queryKeys';
export function ProductPage({ productId }: { productId: number }) {
const product = useTypedQuery(
queryKeys.product(productId),
() => api.getProduct(productId),
);
const reviews = useTypedQuery(
queryKeys.productReviews(productId),
() => api.getReviews(productId),
{ // Reviews change often → keep them fresh
staleTime: 30_000, // 30 s
},
);
// …
}
5.2 Adding a review
// src/components/ReviewForm.tsx
import { useQueryClient } from '@tanstack/react-query';
import { makeInvalidator } from '@/queryInvalidator';
import { queryKeys } from '@/queryKeys';
export function ReviewForm({ productId }: { productId: number }) {
const qc = useQueryClient();
const { invalidate } = makeInvalidator(qc);
const [text, setText] = React.useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await api.postReview(productId, { comment: text, rating: 5 });
// Invalidate *only* the reviews list for this product.
await invalidate(queryKeys.productReviews(productId));
};
return (
<form onSubmit={handleSubmit}>
<textarea value={text} onChange={e => setText(e.target.value)} />
<button type="submit">Submit</button>
</form>
);
}
Because the invalidation helper knows the exact return type of queryKeys.productReviews, you can’t accidentally invalidate the whole product cache or a different product's reviews.
5.3 Deleting a review (optimistic update)
function useDeleteReview(productId: number) {
const qc = useQueryClient();
const { invalidate } = makeInvalidator(qc);
return useMutation(
(reviewId: number) => api.deleteReview(reviewId),
{
// Optimistically remove the review from the cache
onMutate: async (reviewId) => {
await qc.cancelQueries(queryKeys.productReviews(productId));
const previous = qc.getQueryData(queryKeys.productReviews(productId));
qc.setQueryData(queryKeys.productReviews(productId), (old: any) =>
old?.filter((r: any) => r.id !== reviewId),
);
return { previous };
},
// Rollback on error
onError: (_err, _reviewId, ctx) => {
if (ctx?.previous) {
qc.setQueryData(queryKeys.productReviews(productId), ctx.previous);
}
},
// Refetch to guarantee consistency
onSettled: () => invalidate(queryKeys.productReviews(productId)),
},
);
}
The pattern shows type‑safe optimistic updates: we retrieve the exact cache entry using queryKeys.productReviews(productId) and later invalidate the same entry.
6. Dealing with dynamic filters
Sometimes a query key contains a filter object. Because objects are reference‑based, you need a stable reference to avoid unnecessary cache misses.
// src/queryKeys.ts (excerpt)
filteredProducts: (filter: { category?: string; priceMin?: number }) =>
['filteredProducts', filter] as const,
In the component:
function FilteredList({ filter }: { filter: { category?: string; priceMin?: number } }) {
// Memoise the filter object so the key identity stays the same
const stableFilter = React.useMemo(() => filter, [JSON.stringify(filter)]);
const { data } = useTypedQuery(
queryKeys.filteredProducts(stableFilter),
() => api.getFilteredProducts(stableFilter),
{ staleTime: 60_000 },
);
// …
}
If you need to invalidate any filtered list (regardless of filter values), you can pass a partial key:
await invalidate(['filteredProducts']);
The overload in makeInvalidator accepts a generic readonly unknown[], so this call is still type‑checked—you can only invalidate keys that start with a known literal.
7. Testing the type safety
A quick way to see the compile‑time guarantees is to add a type‑only test (e.g., using tsd or a .d.ts file). Example:
// test/queryKey.test-d.ts
import { expectType } from 'tsd';
import { queryKeys } from '@/queryKeys';
type P = ReturnType<typeof queryKeys.product>;
expectType<['product', number]>([] as unknown as P); // passes
// The following line should error – uncomment to verify
// const wrong = ['product', 'string'] as const;
// expectType<P>(wrong);
If the test compiles, your key factory is correctly typed.
8. Summary checklist
| ✅ | Checklist item |
|---|---|
✅ Define all query keys in a single queryKeys object. |
|
✅ Use as const to preserve tuple literals. |
|
✅ Wrap invalidateQueries with a type‑safe helper that only accepts keys from queryKeys. |
|
✅ Create a typed useTypedQuery wrapper to centralise staleTime and other defaults. |
|
| ✅ Memoise objects that appear in keys to avoid unnecessary cache misses. | |
| ✅ Write a tiny type‑only test to guard against future regressions. |
By treating query keys as first‑class, typed values, you eliminate a whole class of runtime bugs that would otherwise surface only in production (stale UI, extra network traffic, or silent data loss). The pattern integrates cleanly with React Query’s built‑in stale‑time mechanisms, giving you both compile‑time and runtime confidence.
9. Further reading
- TanStack Query docs – “Query Keys” and “Invalidation”.
- TypeScript 5.4 – improvements to as const inference.
- “Advanced React Query” talk by Tanner Linsley (covers cache lifetimes).
Feel free to copy the snippets into your own project, adapt the queryKeys shape to your domain, and enjoy a frictionless, type‑safe data layer. Happy querying!
Member discussion