7 min read

Type‑Safe Dependency Invalidation & Cache Staleness in React Query (TS)

Learn how to keep React Query caches fresh and type‑safe using generics, keyof‑based keys, and custom invalidation helpers.
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:

  1. Define a typed query‑key factory that guarantees consistent keys.
  2. Build a generic invalidation helper that only accepts keys derived from that factory.
  3. Show how to combine React Query’s stale‑time and refetch‑on‑window‑focus with the helpers to keep UI fresh.
  4. 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 const tells 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 for filteredProducts).
  • The queryKeys object 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:

  1. View a product’s details (product/:id).
  2. See a paginated list of reviews (product/:id/reviews).
  3. 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!