home / skills / yonatangross / orchestkit / tanstack-query-advanced

tanstack-query-advanced skill

/plugins/ork/skills/tanstack-query-advanced

This skill helps you implement advanced TanStack Query v5 patterns for infinite queries, optimistic updates, and smart caching to improve data UX.

npx playbooks add skill yonatangross/orchestkit --skill tanstack-query-advanced

Review the files below or copy the command above to add this skill to your agents.

Files (5)
SKILL.md
12.7 KB
---
name: tanstack-query-advanced
description: Advanced TanStack Query v5 patterns for infinite queries, optimistic updates, prefetching, gcTime, and queryOptions. Use when building data fetching, caching, or optimistic updates.
tags: [tanstack-query, react-query, caching, infinite-scroll, optimistic-updates, prefetching, suspense]
context: fork
agent: frontend-ui-developer
version: 1.0.0
allowed-tools: [Read, Write, Grep, Glob]
author: OrchestKit
user-invocable: false
---

# TanStack Query Advanced

Production patterns for TanStack Query v5 - server state management done right.

## Overview

- Infinite scroll / pagination
- Optimistic UI updates
- Prefetching for instant navigation
- Complex cache invalidation
- Dependent/parallel queries
- Mutations with rollback

## Core Patterns

### 1. Infinite Queries (Cursor-Based)

```typescript
import { useInfiniteQuery } from '@tanstack/react-query';

interface Page {
  items: Item[];
  nextCursor: string | null;
}

function useInfiniteItems() {
  return useInfiniteQuery({
    queryKey: ['items'],
    queryFn: async ({ pageParam }): Promise<Page> => {
      const res = await fetch(`/api/items?cursor=${pageParam ?? ''}`);
      return res.json();
    },
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    getPreviousPageParam: (firstPage) => firstPage.prevCursor,
  });
}

// Component
function ItemList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteItems();

  return (
    <>
      {data?.pages.flatMap((page) => page.items.map((item) => (
        <ItemCard key={item.id} item={item} />
      )))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more'}
      </button>
    </>
  );
}
```

### 2. Optimistic Updates

```typescript
import { useMutation, useQueryClient } from '@tanstack/react-query';

function useUpdateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateTodo,
    onMutate: async (newTodo) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });

      // Snapshot previous value
      const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);

      // Optimistically update
      queryClient.setQueryData(['todos', newTodo.id], newTodo);

      // Return context for rollback
      return { previousTodo };
    },
    onError: (err, newTodo, context) => {
      // Rollback on error
      queryClient.setQueryData(['todos', newTodo.id], context?.previousTodo);
    },
    onSettled: (data, error, variables) => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['todos', variables.id] });
    },
  });
}
```

### 3. Prefetching Patterns

```typescript
// Prefetch on hover
function UserLink({ userId }: { userId: string }) {
  const queryClient = useQueryClient();

  const prefetchUser = () => {
    queryClient.prefetchQuery({
      queryKey: ['user', userId],
      queryFn: () => fetchUser(userId),
      staleTime: 5 * 60 * 1000, // 5 minutes
    });
  };

  return (
    <Link to={`/users/${userId}`} onMouseEnter={prefetchUser}>
      View User
    </Link>
  );
}

// Prefetch in loader (React Router)
export const loader = (queryClient: QueryClient) => async ({ params }) => {
  await queryClient.ensureQueryData({
    queryKey: ['user', params.id],
    queryFn: () => fetchUser(params.id),
  });
  return null;
};
```

### 4. Smart Cache Invalidation

```typescript
const queryClient = useQueryClient();

// Invalidate exact query
queryClient.invalidateQueries({ queryKey: ['todos', 1] });

// Invalidate all todos queries
queryClient.invalidateQueries({ queryKey: ['todos'] });

// Invalidate with predicate
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'todos' &&
    (query.queryKey[1] as Todo)?.status === 'done',
});

// Invalidate and refetch immediately
queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' });

// Remove from cache entirely
queryClient.removeQueries({ queryKey: ['todos', 1] });
```

### 5. Dependent Queries

```typescript
function useUserPosts(userId: string) {
  // First query
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // Dependent query - only runs when user is loaded
  const postsQuery = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!userQuery.data, // Only fetch when user exists
  });

  return { user: userQuery.data, posts: postsQuery.data };
}
```

### 6. Parallel Queries

```typescript
import { useQueries } from '@tanstack/react-query';

function useMultipleUsers(userIds: string[]) {
  return useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
      staleTime: 5 * 60 * 1000,
    })),
    combine: (results) => ({
      users: results.map((r) => r.data).filter(Boolean),
      pending: results.some((r) => r.isPending),
      error: results.find((r) => r.error)?.error,
    }),
  });
}
```

### 7. Query Deduplication & Batching

```typescript
// Configure in QueryClient
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60, // 1 minute
      gcTime: 1000 * 60 * 5, // 5 minutes (formerly cacheTime)
      refetchOnWindowFocus: false,
      retry: 3,
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
});
```

### 8. Suspense Integration

```typescript
import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  // This will suspend until data is ready
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  return <div>{user.name}</div>;
}

// Wrap with Suspense
<Suspense fallback={<Skeleton />}>
  <UserProfile userId="123" />
</Suspense>
```

### 9. Mutation State Tracking

```typescript
import { useMutationState } from '@tanstack/react-query';

function PendingTodos() {
  // Track all pending todo mutations
  const pendingMutations = useMutationState({
    filters: { mutationKey: ['addTodo'], status: 'pending' },
    select: (mutation) => mutation.state.variables as Todo,
  });

  return (
    <>
      {pendingMutations.map((todo) => (
        <TodoItem key={todo.id} todo={todo} isPending />
      ))}
    </>
  );
}
```

## Configuration Best Practices

```typescript
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,       // Data fresh for 1 min
      gcTime: 1000 * 60 * 5,      // Cache for 5 min
      refetchOnWindowFocus: true, // Refetch on tab focus
      refetchOnReconnect: true,   // Refetch on network reconnect
      retry: 3,                   // Retry failed requests
    },
    mutations: {
      retry: 1,
      onError: (error) => toast.error(error.message),
    },
  },
});
```

## Quick Reference

```typescript
// ✅ Create typed query with queryOptions helper (v5)
const userQueryOptions = (id: string) => queryOptions({
  queryKey: ['user', id] as const,
  queryFn: () => fetchUser(id),
  staleTime: 5 * 60 * 1000,
});

// ✅ Use the query options for consistency
const { data } = useQuery(userQueryOptions(userId));
await queryClient.prefetchQuery(userQueryOptions(userId));
await queryClient.ensureQueryData(userQueryOptions(userId));

// ✅ v5: isPending instead of isLoading (initial load only)
if (isPending) return <Skeleton />;

// ✅ v5: gcTime instead of cacheTime
gcTime: 5 * 60 * 1000,

// ✅ useSuspenseQuery for Suspense integration
const { data } = useSuspenseQuery(userQueryOptions(userId));

// ✅ Selective invalidation
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true });

// ❌ NEVER destructure useQuery result at call site
const { data, isLoading } = useQuery({ queryKey: ['users'] }); // BAD - recreates object

// ❌ NEVER use string keys
useQuery({ queryKey: 'users' }); // BAD - use arrays

// ❌ NEVER store server state in Zustand
const useStore = create((set) => ({ users: [] })); // BAD - use React Query
```

## Key Decisions

| Decision | Option A | Option B | Recommendation |
|----------|----------|----------|----------------|
| Query key structure | String | Array | **Array** - supports hierarchy and serialization |
| Cache timing | staleTime | gcTime | **Both** - staleTime for freshness, gcTime for memory |
| Loading state | isLoading | isPending | **isPending** (v5) - isLoading includes background refetches |
| Query definition | Inline | queryOptions | **queryOptions** - reusable for prefetch/loader/useQuery |
| Suspense | useQuery + loading | useSuspenseQuery | **useSuspenseQuery** for React 18+ Suspense |
| Optimistic updates | setQueryData only | setQueryData + invalidate | **Both** - optimistic then reconcile |
| Parallel queries | Multiple useQuery | useQueries | **useQueries** - combined loading/error state |
| Infinite queries | Manual pagination | useInfiniteQuery | **useInfiniteQuery** - built-in cursor handling |

## Anti-Patterns (FORBIDDEN)

```typescript
// ❌ FORBIDDEN: Storing server state in Zustand/Redux
const useStore = create((set) => ({
  users: [],  // Server state belongs in React Query!
  fetchUsers: async () => {
    const users = await api.getUsers();
    set({ users }); // Stale data, no background refetch
  },
}));

// ❌ FORBIDDEN: String query keys
useQuery({
  queryKey: 'todos',  // Must be an array!
  queryFn: fetchTodos,
});

// ❌ FORBIDDEN: Using deprecated cacheTime (v5)
useQuery({
  queryKey: ['todos'],
  cacheTime: 5 * 60 * 1000,  // WRONG - use gcTime in v5
});

// ❌ FORBIDDEN: Using isLoading for initial state (v5)
// isLoading = isPending && isFetching (includes background refetch)
if (isLoading) return <Skeleton />;  // WRONG - use isPending

// ❌ FORBIDDEN: Over-invalidating after mutations
useMutation({
  mutationFn: updateTodo,
  onSuccess: () => {
    queryClient.invalidateQueries(); // Invalidates EVERYTHING!
  },
});

// ❌ FORBIDDEN: Forgetting to cancel queries in optimistic updates
useMutation({
  onMutate: async (newTodo) => {
    // Missing: await queryClient.cancelQueries(...)
    const previous = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
    return { previous };
  },
});

// ❌ FORBIDDEN: Not returning context from onMutate
useMutation({
  onMutate: async (newTodo) => {
    const previous = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
    // Missing: return { previous }; // Required for rollback!
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context?.previous); // context is undefined!
  },
});

// ❌ FORBIDDEN: Mutating cache data directly
queryClient.setQueryData(['todos'], (old) => {
  old.push(newTodo);  // WRONG - mutates existing array
  return old;
});
// ✅ CORRECT: Return new array
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);

// ❌ FORBIDDEN: Fetching inside useEffect
useEffect(() => {
  fetch('/api/users').then(setUsers);  // Use React Query instead!
}, []);
```

## Related Skills

- `zustand-patterns` - Client state management (use alongside React Query for server state)
- `form-state-patterns` - Form state with React Hook Form (integrate mutation status)
- `msw-mocking` - Mock Service Worker for testing queries without network
- `react-server-components-framework` - RSC hydration with React Query

## Capability Details

### infinite-queries
**Keywords**: infinite, pagination, cursor, load more, scroll, pages
**Solves**: Implementing cursor-based pagination with automatic page management

### optimistic-updates
**Keywords**: optimistic, instant, rollback, onMutate, setQueryData, cancel
**Solves**: Showing immediate UI feedback before server confirmation with rollback

### prefetching
**Keywords**: prefetch, hover, preload, ensureQueryData, loader, navigation
**Solves**: Loading data before it's needed for instant navigation

### cache-invalidation
**Keywords**: invalidate, refetch, stale, fresh, gcTime, staleTime, exact
**Solves**: Keeping cache in sync with server after mutations

### suspense-integration
**Keywords**: suspense, useSuspenseQuery, streaming, fallback, boundary
**Solves**: Integrating with React Suspense for declarative loading states

### parallel-queries
**Keywords**: useQueries, parallel, concurrent, combine, batch
**Solves**: Fetching multiple independent queries with combined state

## References

- `references/cache-strategies.md` - Cache invalidation patterns
- `scripts/query-hooks-template.ts` - Production query hook template
- `checklists/tanstack-checklist.md` - Implementation checklist
- `examples/tanstack-examples.md` - Real-world usage examples

Overview

This skill delivers production-ready TanStack Query v5 patterns for advanced server-state management in TypeScript projects. It bundles implementations and recommendations for infinite queries, optimistic updates, prefetching, gcTime, queryOptions, and more to make data fetching predictable and performant. Use it to standardize caching, pagination, and mutation workflows across React apps and loaders.

How this skill works

The skill provides reusable hooks, queryOptions factories, and QueryClient configurations that encode best practices for v5. It includes cursor-based infinite queries, onMutate optimistic update patterns with rollback context, prefetch/ensureQueryData helpers, and smart invalidation/prefetch timing via staleTime and gcTime. Patterns show how to combine dependent and parallel queries, integrate with Suspense, and track mutation state for UI feedback.

When to use it

  • Building cursor-based infinite scroll or paginated lists
  • Implementing optimistic UI for fast, offline-friendly interactions
  • Prefetching data on hover or in route loaders to eliminate load flashes
  • Configuring global query defaults (staleTime, gcTime, retry policies)
  • Coordinating dependent or parallel data requests in complex UIs

Best practices

  • Use array query keys and typed queryOptions for reuse and serialization
  • Cancel outgoing queries and return onMutate context to enable safe rollbacks
  • Prefer gcTime (v5) with staleTime for freshness and memory control
  • Avoid destructuring useQuery at call site; pass the options object instead
  • Prefetch with ensureQueryData in loaders and prefetchQuery on hover for instant navigation

Example use cases

  • An infinite product feed using useInfiniteQuery with cursor-based pagination
  • Optimistic todo updates: setQueryData + onError rollback + invalidate onSettled
  • Prefetching user details on Link hover and ensuring data in route loaders
  • Combining useQueries to fetch multiple user records with a single combined state
  • Integrating useSuspenseQuery for React Suspense-based profile pages

FAQ

When should I use gcTime vs staleTime?

Use staleTime to control how long data is considered fresh for refetch suppression, and use gcTime to control how long the cache remains in memory after becoming unused. Both work together for freshness and memory management.

How do I safely implement optimistic updates?

Cancel outgoing queries, snapshot current data with getQueryData, set optimistic state with setQueryData, return the snapshot from onMutate, and rollback in onError using the returned context; invalidate onSettled to reconcile.