Introduction to State Management in TanStack Query
TanStack Query (formerly React Query) has revolutionized how developers handle server state in modern web applications. While it is primarily known as a data-fetching library, its true power lies in how it manages state โ specifically server state โ which is fundamentally different from the client state we traditionally manage with tools like Redux or Zustand.
In this tutorial, we will explore the patterns, strategies, and complementary libraries that make TanStack Query a complete state management solution for production-grade applications.
What Is State Management in TanStack Query?
State management in TanStack Query refers to how the library handles, caches, synchronizes, and updates server state across your application. Unlike traditional state management libraries that treat all state the same, TanStack Query recognizes that server state has unique characteristics:
- It is owned by a remote server, not the client.
- It can become stale without the client knowing.
- It requires asynchronous APIs to fetch and update.
- It must be shared across multiple components efficiently.
- It needs careful handling of loading, error, and success states.
TanStack Query manages all of this through its QueryClient, which acts as a centralized store for all server state. Each query is identified by a unique queryKey, and the client handles caching, background refetching, garbage collection, and synchronization automatically.
Why It Matters
Before TanStack Query, developers typically stuffed server data into global stores like Redux. This led to several problems: boilerplate-heavy code for async actions, manual cache invalidation, duplicated loading and error flags, and stale data lingering in the store. TanStack Query eliminates these issues by treating server state as a first-class citizen with built-in lifecycle management.
The result is cleaner code, fewer bugs related to stale data, automatic background synchronization, and a dramatic reduction in the amount of state you need to manage manually. You can often remove a global state library entirely for server data, reserving it only for genuine client-side UI state.
Getting Started: Setting Up the QueryClient
The first step is to create a QueryClient instance and provide it to your application. This client is the heart of state management in TanStack Query.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
refetchOnWindowFocus: true,
retry: 2,
},
},
});
export function QueryProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
The defaultOptions allow you to configure global behavior for all queries. This is where you establish your application's caching strategy, which is a core part of state management.
Core Patterns for State Management
1. Query Keys as State Identifiers
Query keys are arrays that uniquely identify a piece of server state. They function similarly to keys in a Redux store, but with the added benefit of supporting hierarchical and parameterized lookups.
// Simple key
useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Parameterized key
useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) });
// Hierarchical key with filters
useQuery({
queryKey: ['users', { status: 'active', page: 2 }],
queryFn: () => fetchUsers({ status: 'active', page: 2 }),
});
By organizing keys hierarchically, you enable powerful invalidation patterns. For example, invalidating ['users'] will invalidate all queries that start with that key, including ['users', 1] and ['users', { status: 'active' }].
2. Reading and Mutating the Cache Directly
Sometimes you need to read or update cached state without triggering a network request. The QueryClient provides methods for direct cache manipulation, which is essential for optimistic updates and pre-populating data.
import { useQueryClient } from '@tanstack/react-query';
function useUpdateUserName() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: { id: string; name: string }) =>
api.updateUser(variables.id, { name: variables.name }),
onMutate: async (variables) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users', variables.id] });
// Snapshot the previous value
const previousUser = queryClient.getQueryData(['users', variables.id]);
// Optimistically update the cache
queryClient.setQueryData(['users', variables.id], (old: User) => ({
...old,
name: variables.name,
}));
return { previousUser };
},
onError: (_err, variables, context) => {
// Roll back on error
if (context?.previousUser) {
queryClient.setQueryData(['users', variables.id], context.previousUser);
}
},
onSettled: (data, _err, variables) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['users', variables.id] });
},
});
}
This optimistic update pattern is one of the most important state management techniques in TanStack Query. It gives users instant feedback while keeping the server as the source of truth.
3. Prefetching and Seeding State
Prefetching allows you to populate the cache before a component even mounts. This is invaluable for route-level data loading and hover-based prefetching.
import { useQueryClient } from '@tanstack/react-query';
function usePrefetchUser() {
const queryClient = useQueryClient();
const prefetchUser = (userId: string) => {
queryClient.prefetchQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 2,
});
};
return prefetchUser;
}
// Usage in a list component
function UserList({ users }: { users: User[] }) {
const prefetchUser = usePrefetchUser();
return (
<ul>
{users.map((user) => (
<li
key={user.id}
onMouseEnter={() => prefetchUser(user.id)}
>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
);
}
4. Derived State with select
The select option lets you derive a specific slice of state from a query's data. This is the TanStack Query equivalent of selectors in Redux, and it helps prevent unnecessary re-renders.
function useUserEmail(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
select: (data) => data.email,
});
}
function useActiveUsers() {
return useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (data) => data.filter((user) => user.status === 'active'),
});
}
The select function uses structural sharing to compare results, so components only re-render when the selected slice actually changes.
5. Combining Server State with Client State
Not all state belongs in TanStack Query. UI-specific state like modal visibility, form drafts, and theme toggles should live in a lightweight client state solution. The recommended pattern is to use TanStack Query for server state and a small library like Zustand for client state.
import { create } from 'zustand';
interface UIState {
sidebarOpen: boolean;
toggleSidebar: () => void;
selectedUserId: string | null;
setSelectedUserId: (id: string | null) => void;
}
export const useUIStore = create<UIState>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
selectedUserId: null,
setSelectedUserId: (id) => set({ selectedUserId: id }),
}));
// Combining both in a component
function UserDetailPanel() {
const selectedUserId = useUIStore((s) => s.selectedUserId);
const { data: user, isLoading } = useQuery({
queryKey: ['users', selectedUserId],
queryFn: () => fetchUser(selectedUserId!),
enabled: !!selectedUserId,
});
if (!selectedUserId) return <p>Select a user</p>;
if (isLoading) return <p>Loading...</p>;
return <div>{user?.name}</div>;
}
Notice the enabled option โ it allows you to conditionally activate a query based on client state, creating a clean bridge between the two state worlds.
Advanced State Management Patterns
Dependent Queries
When one query depends on the result of another, you can chain them using the enabled flag and the data from the first query.
function useUserProjects(userId: string | undefined) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId!),
enabled: !!userId,
});
const projectsQuery = useQuery({
queryKey: ['projects', user?.organizationId],
queryFn: () => fetchProjects(user!.organizationId),
enabled: !!user?.organizationId,
});
return projectsQuery;
}
Initial Data and Placeholder Data
For smoother UX, you can provide initial or placeholder data while the real data loads. initialData seeds the cache as if it were real data, while placeholderData is shown temporarily and does not affect cache state.
// Using initial data from a list query for a detail query
function useUserDetail(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
initialData: () => {
// Try to find the user in the cached list
return queryClient.getQueryData<User[]>(['users'])?.find(
(u) => u.id === userId
);
},
staleTime: 1000 * 60, // Treat initial data as stale quickly
});
}
Query Cancellation
TanStack Query supports query cancellation via AbortSignal. This is important for preventing race conditions when state changes rapidly.
async function fetchSearchResults(
query: string,
signal?: AbortSignal
): Promise<Result[]> {
const response = await fetch(`/api/search?q=${query}`, { signal });
if (!response.ok) throw new Error('Search failed');
return response.json();
}
function useSearch(query: string) {
return useQuery({
queryKey: ['search', query],
queryFn: ({ signal }) => fetchSearchResults(query, signal),
enabled: query.length > 2,
});
}
Complementary Libraries and Tools
TanStack Query Devtools
The official Devtools extension is essential for debugging state. It shows you the full cache, query statuses, active observers, and a timeline of actions. Install it as a development dependency and mount it in your app.
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryProvider>
<Router />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryProvider>
);
}
Zustand for Client State
As shown earlier, Zustand pairs perfectly with TanStack Query. It is minimal, has no boilerplate, and works seamlessly alongside the query cache for UI-only state.
Immer for Immutable Cache Updates
When performing complex optimistic updates, Immer can simplify the process of producing new immutable state from the existing cache.
import { produce } from 'immer';
queryClient.setQueryData<User[]>(['users'], (old) =>
produce(old, (draft) => {
const user = draft?.find((u) => u.id === updatedId);
if (user) user.name = newName;
})
);
Zod for Runtime Validation
Since server state comes from an untrusted source, validating it at the boundary is a best practice. Zod schemas can be integrated into your query functions to ensure type safety.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
status: z.enum(['active', 'inactive']),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const json = await res.json();
return UserSchema.parse(json);
}
Best Practices
- Separate server state from client state. Do not duplicate server data in a global store. Let TanStack Query own it.
- Use consistent query key factories. Centralize key creation to avoid typos and enable easier invalidation.
- Set sensible stale times. Not all data needs to be fresh immediately. Tune
staleTimeper query type. - Invalidate strategically. Prefer targeted invalidation over broad refetches to minimize network traffic.
- Always handle errors. Use the
errorstate from queries and mutations to provide user feedback. - Leverage optimistic updates. They dramatically improve perceived performance for mutations.
- Prefetch on interaction. Hover and focus events are great opportunities to warm the cache.
- Validate at the boundary. Use Zod or similar to ensure server state matches your TypeScript types.
Query Key Factory Pattern
One of the most effective organizational patterns is the query key factory. It centralizes all keys and associated query functions in one place.
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,
};
// Usage
useQuery({ queryKey: userKeys.detail(userId), queryFn: () => fetchUser(userId) });
queryClient.invalidateQueries({ queryKey: userKeys.all });
Conclusion
TanStack Query is far more than a data-fetching utility โ it is a purpose-built state management system for server state that handles caching, synchronization, invalidation, and optimistic updates out of the box. By understanding the patterns covered in this tutorial โ query key factories, optimistic mutations, prefetching, derived state with select, and the clean separation of server and client state โ you can build applications that are both simpler and more robust than those built with traditional global state libraries. Pair TanStack Query with a lightweight client state solution like Zustand, validate your data at the boundary with Zod, and use the Devtools to maintain visibility into your cache, and you will have a state management architecture that scales gracefully from small projects to large enterprise applications.