Post

Server State with TanStack Query

Server State with TanStack Query

Series Overview

  1. TypeScript Patterns for React
  2. Type-Safe Routing with TanStack Router
  3. Server State with TanStack Query (this post)
  4. Styling with Tailwind CSS v4 & shadcn/ui
  5. Modular Feature Architecture
  6. Deploy to AWS with S3, CloudFront & CDK

Introduction

Traditional React data fetching uses useEffect + useState + loading/error flags. This leads to duplicated fetch logic, no caching, no background updates, manual state management, and race conditions on rapid navigations. TanStack Query replaces all of this with a declarative, cache-first approach.

The core mental model is straightforward: TanStack Query is an async state manager, not a data fetcher. You provide the Promise; it manages caching, deduplication, background refetching, garbage collection, retry logic, and reactive state updates automatically.

This post covers installation, the critical staleTime vs gcTime distinction, query keys, the queryOptions pattern, reading data, writing data with mutations, optimistic updates, router integration, infinite queries, and testing.

Installation and Setup

1
2
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools

QueryClient and Provider

Create a QueryClient and wrap your app in QueryClientProvider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,
      gcTime: 1000 * 60 * 5,
      refetchOnWindowFocus: false,
      retry: 3,
      retryDelay: (attempt) =>
        Math.min(1000 * 2 ** attempt, 30000),
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Set staleTime and gcTime globally, then override per-query where needed. Common recommended defaults for SPAs:

SettingLibrary DefaultRecommended SPA DefaultWhy
staleTime060_000 (1 min)Prevents unnecessary refetches on mount
gcTime300_000 (5 min)300_000 (5 min)Keeps inactive cache for back-navigation
refetchOnWindowFocustruefalseSPAs do not need aggressive refetching
retry33Handles transient network failures

Core Concepts: staleTime vs gcTime

These two settings control how TanStack Query manages cached data. Understanding them is essential.

staleTime: How Long Is This Data Fresh?

While data is fresh, TanStack Query serves it from cache without any network request – even if the component remounts, the user navigates away and back, or the window regains focus.

Once staleTime elapses, data becomes stale. Stale data is still served from cache instantly, but a background refetch is triggered under certain conditions (component mount, window focus, network reconnect, or interval).

gcTime: How Long to Keep Unused Data in Memory?

When a query has no active observers (no component is using it), a timer starts. After gcTime elapses, the cached entry is garbage collected.

If the component remounts before gcTime expires, the cached data is available instantly (and a background refetch may occur if data is stale).

The Relationship

1
2
3
fetch --- fresh (staleTime) --- stale --- component unmounts --- gcTime expires --- deleted
                                  |                                  |
                                  +-- background refetch             +-- next mount = fresh fetch

Rule of thumb: Keep gcTime >= staleTime. This ensures cached data is still available when it goes stale, enabling the “instant cache + background refetch” pattern.

Per-Query Configuration Examples

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Static data (rarely changes)
useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: Infinity,
  gcTime: Infinity,
});

// User profile (moderate freshness)
useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 30_000,
  gcTime: 300_000,
});

// Live stock price (very short staleTime)
useQuery({
  queryKey: ['stock', ticker],
  queryFn: () => fetchStockPrice(ticker),
  staleTime: 5_000,
  refetchInterval: 10_000,
});

Query Keys: Cache Identity

Query keys are the cache identity for your data. Two queries with the same key share the same cache entry. Keys are serialized deterministically, so object property order does not matter.

Create a factory object that centralizes all your query keys. This prevents key typos and makes cache invalidation predictable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const userKeys = {
  all:     ['users'] as const,
  lists:   () => [...userKeys.all, 'list'] as const,
  list:    (filters: UserFilters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail:  (id: string) => [...userKeys.details(), id] as const,
};

export const postKeys = {
  all:     ['posts'] as const,
  lists:   () => [...postKeys.all, 'list'] as const,
  list:    (filters: PostFilters) => [...postKeys.lists(), filters] as const,
  details: () => [...postKeys.all, 'detail'] as const,
  detail:  (id: string) => [...postKeys.details(), id] as const,
};

Usage in queries and invalidation:

1
2
3
4
5
6
7
8
// Fetch a single user
useQuery({ queryKey: userKeys.detail(userId), queryFn: ... });

// Invalidate all user queries (lists + details)
queryClient.invalidateQueries({ queryKey: userKeys.all });

// Invalidate only user lists (not details)
queryClient.invalidateQueries({ queryKey: userKeys.lists() });

The queryOptions Pattern

TanStack Query v5 introduced queryOptions() – a helper that bundles queryKey, queryFn, and options into a single reusable object. This is the recommended way to define queries.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { queryOptions } from '@tanstack/react-query';

export function userDetailOptions(userId: string) {
  return queryOptions({
    queryKey: userKeys.detail(userId),
    queryFn: () => fetchUser(userId),
    staleTime: 30_000,
  });
}

export function userListOptions(filters: UserFilters) {
  return queryOptions({
    queryKey: userKeys.list(filters),
    queryFn: () => fetchUsers(filters),
    staleTime: 60_000,
  });
}

Why This Matters

The same queryOptions object can be used in three different places:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. In a TanStack Router loader (prefetch)
export const Route = createFileRoute('/users/$userId')({
  loader: ({ params, context: { queryClient } }) =>
    queryClient.ensureQueryData(userDetailOptions(params.userId)),
  component: UserPage,
});

// 2. In a component (reactive cache read + background refetch)
function UserPage() {
  const { userId } = Route.useParams();
  const { data: user } = useQuery(userDetailOptions(userId));
}

// 3. In a prefetch-on-hover handler
<Link
  to="/users/$userId"
  params=
  onMouseEnter={() =>
    queryClient.prefetchQuery(userDetailOptions('42'))
  }
>
  View User
</Link>

Inline useQuery({ queryKey, queryFn }) works, but duplicates key-function pairs. queryOptions creates a single source of truth that ensures the queryKey and queryFn are always paired correctly.

Queries: Reading Data

Basic Query

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  const {
    data,        // The fetched data (typed from queryFn return)
    isPending,   // True on first load (no cached data)
    isError,     // True if queryFn threw
    error,       // The error object
    isStale,     // True after staleTime elapses
    isFetching,  // True during any fetch (including background)
    refetch,     // Manual refetch function
  } = useQuery(userDetailOptions(userId));

  if (isPending) return <Spinner />;
  if (isError) return <ErrorMessage error={error} />;

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

Dependent (Serial) Queries

When one query depends on another’s result:

1
2
3
4
5
6
7
const { data: user } = useQuery(userDetailOptions(userId));

const { data: projects } = useQuery({
  queryKey: ['projects', user?.orgId],
  queryFn: () => fetchProjectsByOrg(user!.orgId),
  enabled: !!user?.orgId,
});

Parallel Queries

Multiple independent queries run in parallel automatically:

1
2
3
const userQuery = useQuery(userDetailOptions(userId));
const postsQuery = useQuery(postListOptions({ authorId: userId }));
// Both fetch simultaneously

Mutations: Writing Data

Mutations handle create, update, and delete operations. They are not cached – they run only when you call mutate() or mutateAsync().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useMutation, useQueryClient } from '@tanstack/react-query';

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

  const createPost = useMutation({
    mutationFn: (newPost: NewPost) =>
      fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(newPost),
      }).then((res) => res.json()),

    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: postKeys.lists() });
    },
    onError: (error) => {
      toast.error(`Failed: ${error.message}`);
    },
  });

  return (
    <button
      onClick={() => createPost.mutate({ title: 'New Post', body: '...' })}
      disabled={createPost.isPending}
    >
      {createPost.isPending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

mutate() vs mutateAsync()

  • mutate() – Fire-and-forget. Errors go to the mutation’s error state. Use this for most form submissions.
  • mutateAsync() – Returns a Promise. Use when you need to await the result before doing something else (e.g., navigate after creation).
1
2
3
4
const handleSubmit = async (data: NewPost) => {
  const created = await createPost.mutateAsync(data);
  navigate({ to: '/posts/$postId', params: { postId: created.id } });
};

Optimistic Updates

Show the result immediately, then reconcile when the server responds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const updateTodo = useMutation({
  mutationFn: (updatedTodo: Todo) => patchTodo(updatedTodo),
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: todoKeys.detail(newTodo.id) });
    const previousTodo = queryClient.getQueryData(todoKeys.detail(newTodo.id));
    queryClient.setQueryData(todoKeys.detail(newTodo.id), newTodo);
    return { previousTodo };
  },
  onError: (_err, _newTodo, context) => {
    if (context?.previousTodo) {
      queryClient.setQueryData(
        todoKeys.detail(context.previousTodo.id),
        context.previousTodo
      );
    }
  },
  onSettled: (_data, _err, variables) => {
    queryClient.invalidateQueries({ queryKey: todoKeys.detail(variables.id) });
  },
});

Use optimistic updates for actions where the user expects instant feedback: toggling a favorite, marking a task complete, updating a name. Avoid them for complex operations where server-side validation might reject the change.

Prefetching and Router Integration

Prefetch in TanStack Router Loaders

The recommended pattern: prefetch in the route loader, read in the component.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Route definition
export const Route = createFileRoute('/posts/$postId')({
  loader: ({ params, context: { queryClient } }) =>
    queryClient.ensureQueryData(postDetailOptions(params.postId)),
  component: PostPage,
});

// Component
function PostPage() {
  const { postId } = Route.useParams();
  const { data } = useQuery(postDetailOptions(postId));
  // data is available INSTANTLY from the loader's cache
}

ensureQueryData vs prefetchQuery:

  • ensureQueryData – Returns the data. If stale, fetches fresh data. If already fresh in cache, returns cached data without a network call.
  • prefetchQuery – Returns void. Fires a fetch if stale, but does not wait. Useful for fire-and-forget prefetching on hover.

Prefetch on Hover

With defaultPreload: 'intent' on the router, all <Link> components automatically prefetch route data and code on hover/focus.

Infinite Queries (Pagination)

For load-more or infinite scroll patterns:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { useInfiniteQuery } from '@tanstack/react-query';

function PostsFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam }) =>
      fetch(`/api/posts?cursor=${pageParam}`).then(r => r.json()),
    initialPageParam: '',
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];

  return (
    <div>
      {allPosts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Testing

Use Mock Service Worker (MSW) to intercept network requests in tests. Always create a new QueryClient for each test to avoid shared cache state.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

test('fetches user data', async () => {
  const { result } = renderHook(
    () => useQuery(userDetailOptions('1')),
    { wrapper: createWrapper() }
  );

  await waitFor(() => expect(result.current.isSuccess).toBe(true));
  expect(result.current.data?.name).toBe('Alice');
});

Common Anti-Patterns to Avoid

Using useEffect to Fetch Data

1
2
3
4
5
6
7
8
9
10
11
// Don't do this
const [data, setData] = useState(null);
useEffect(() => {
  fetch('/api/users').then(r => r.json()).then(setData);
}, []);

// Do this instead
const { data } = useQuery({
  queryKey: ['users'],
  queryFn: () => fetch('/api/users').then(r => r.json()),
});

Putting Server State in Redux/Zustand

TanStack Query IS your server-state manager. Use Zustand or Redux only for client state (UI toggles, form state, theme preferences).

Forgetting to Set staleTime

With the default staleTime: 0, every component mount triggers a background refetch. For most SPAs, setting staleTime to 30-60 seconds globally prevents excessive network requests.

Using the Same Query Key for Different Data

1
2
3
4
5
6
7
// Bad: both return different data but share a cache entry
useQuery({ queryKey: ['data'], queryFn: fetchUsers });
useQuery({ queryKey: ['data'], queryFn: fetchPosts });

// Correct: distinct keys for distinct data
useQuery({ queryKey: ['users'], queryFn: fetchUsers });
useQuery({ queryKey: ['posts'], queryFn: fetchPosts });

Conclusion

TanStack Query transforms how you think about server data in React. Instead of imperatively fetching and managing loading states, you declare what data you need and let the library handle the rest: caching, deduplication, background updates, retries, and garbage collection. Combined with the queryOptions pattern and TanStack Router’s loader integration, you get instant navigations with always-fresh data.

In the next post, we will build the visual layer of our SPA using Tailwind CSS v4 and shadcn/ui – a design system that gives you beautiful, accessible components with full theme control.

References

This post is licensed under CC BY 4.0 by the author.