React Query (TanStack Query) revolutionizes server state management in React. Let's master it from setup to advanced patterns.
Why React Query?
React Query solves:
- Caching: Automatic caching and revalidation
- Background updates: Keep data fresh
- Deduplication: Avoid duplicate requests
- Pagination: Built-in pagination support
- Optimistic updates: Smooth UI updates
- Infinite scrolling: Easy implementation
- DevTools: Debugging made easy
Installation
bash
npm install @tanstack/react-queryBasic Setup
Configure Provider
typescript
// index.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 10, // 10 minutes
retry: 3,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);Queries
Basic Query
typescript
import { useQuery } from '@tanstack/react-query';
function Users() {
const { data, isLoading, error, isError } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
},
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map((user: User) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Query with Parameters
typescript
function User({ userId }: { userId: number }) {
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`https://api.example.com/users/${userId}`);
return response.json();
},
enabled: !!userId, // Only run if userId exists
});
if (isLoading) return <div>Loading...</div>;
if (!data) return null;
return <div>{data.name}</div>;
}Dependent Queries
typescript
function UserPosts({ userId }: { userId: number }) {
// First query: get user
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// Second query: get posts (only if user exists)
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // Dependent on first query
});
if (!user || !posts) return <div>Loading...</div>;
return (
<div>
<h1>{user.name}'s Posts</h1>
{posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}Pagination
Basic Pagination
typescript
function PaginatedUsers() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
});
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : (
<>
<ul>
{data?.users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!data?.hasMore}
>
Next
</button>
</>
)}
</div>
);
}Infinite Scroll
typescript
import { useInfiniteQuery } from '@tanstack/react-query';
function InfiniteUsers() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['users'],
queryFn: ({ pageParam = 1 }) => fetchUsers(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextPage : undefined,
});
const observer = useRef<IntersectionObserver>();
const lastElementRef = useCallback(
(node) => {
if (isFetchingNextPage) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (node) observer.current.observe(node);
},
[isFetchingNextPage, fetchNextPage, hasNextPage]
);
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data?.pages.map((page) => (
<div key={page.page}>
{page.users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
))}
<div ref={lastElementRef}>
{isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load more' : 'Nothing more to load'}
</div>
</div>
);
}Mutations
Basic Mutation
typescript
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUser() {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const mutation = useMutation({
mutationFn: (newUser: { name: string }) =>
fetch('https://api.example.com/users', {
method: 'POST',
body: JSON.stringify(newUser),
}).then((res) => res.json()),
onSuccess: (data) => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
console.log('User created:', data);
},
onError: (error) => {
console.error('Error creating user:', error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ name });
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="User name"
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <div>Error: {mutation.error.message}</div>}
</form>
);
}Optimistic Updates
typescript
function UpdateUser({ userId }: { userId: number }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (updates: Partial<User>) =>
fetch(`https://api.example.com/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(updates),
}).then((res) => res.json()),
onMutate: async (newData) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['user', userId] });
// Snapshot previous value
const previousUser = queryClient.getQueryData(['user', userId]);
// Optimistically update to new value
queryClient.setQueryData(['user', userId], (old: User) => ({
...old,
...newData,
}));
// Return context with previous value
return { previousUser };
},
onError: (err, newData, context) => {
// Rollback to previous value
queryClient.setQueryData(['user', userId'], context?.previousUser);
},
onSettled: () => {
// Refetch to ensure server state
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
}Cache Management
Manual Cache Updates
typescript
function AddToCart({ productId }: { productId: number }) {
const queryClient = useQueryClient();
const addToCart = () => {
// Update cart cache
queryClient.setQueryData(['cart'], (old: Cart) => ({
...old,
items: [...old.items, productId],
}));
};
return <button onClick={addToCart}>Add to Cart</button>;
}Invalidate Queries
typescript
// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['users'] });
// Invalidate all queries starting with 'users'
queryClient.invalidateQueries({ queryKey: ['users'] });
// Invalidate multiple queries
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'users',
});Reset Queries
typescript
// Reset specific query
queryClient.resetQueries({ queryKey: ['users'] });
// Reset all queries
queryClient.resetQueries();Remove Queries
typescript
// Remove specific query
queryClient.removeQueries({ queryKey: ['users'] });
// Remove inactive queries
queryClient.removeQueries({ inactive: true });Advanced Patterns
Parallel Queries
typescript
function Dashboard() {
// Run queries in parallel
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const postsQuery = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats });
if (usersQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>{usersQuery.data?.length} Users</h1>
<h1>{postsQuery.data?.length} Posts</h1>
<h1>{statsQuery.data?.views} Views</h1>
</div>
);
}Dynamic Query Keys
typescript
// Query key factory
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: string) => [...userKeys.lists(), { filters }] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
};
// Usage
useQuery({
queryKey: userKeys.list('active'),
queryFn: () => fetchUsers('active'),
});
// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: userKeys.all });
// Invalidate only user lists
queryClient.invalidateQueries({ queryKey: userKeys.lists() });Custom Hooks
typescript
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useUsers(filters?: string) {
return useQuery({
queryKey: ['users', filters],
queryFn: () => fetchUsers(filters),
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
export function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
enabled: !!id,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateUserInput) => createUser(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateUserInput }) =>
updateUser(id, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: ['user', id] });
const previousUser = queryClient.getQueryData(['user', id]);
queryClient.setQueryData(['user', id'], (old: User) => ({
...old,
...data,
}));
return { previousUser };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['user', variables.id], context?.previousUser);
},
onSettled: (_, __, { id }) => {
queryClient.invalidateQueries({ queryKey: ['user', id] });
},
});
}
// Usage in components
function UserList() {
const { data, isLoading } = useUsers();
const createUser = useCreateUser();
// ...
}Error Handling
Retry Configuration
typescript
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
retry: 3, // Retry 3 times on failure
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
});Error Boundaries
typescript
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
return (
<div>
<h1>Something went wrong</h1>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Users />
</ErrorBoundary>
);
}Performance Optimization
Selectors
typescript
// Select specific data to reduce re-renders
const userName = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
select: (data) => data.name, // Only name
});Cache Time Configuration
typescript
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
},
},
});Prefetching
typescript
// Prefetch data on hover
function UserLink({ userId }: { userId: number }) {
const queryClient = useQueryClient();
const prefetchUser = () => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
};
return (
<a href={`/user/${userId}`} onMouseEnter={prefetchUser}>
View User
</a>
);
}DevTools
Setup DevTools
typescript
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}Best Practices
1. Organize Query Keys
typescript
// Query key factory pattern
const queryKeys = {
users: {
all: ['users'] as const,
lists: () => [...queryKeys.users.all, 'list'] as const,
list: (filters: string) => [...queryKeys.users.lists(), { filters }] as const,
details: () => [...queryKeys.users.all, 'detail'] as const,
detail: (id: number) => [...queryKeys.users.details(), id] as const,
},
};2. Create Custom Hooks
typescript
// Reusable query hooks
export const useUsers = (filters?: string) =>
useQuery({
queryKey: queryKeys.users.list(filters),
queryFn: () => fetchUsers(filters),
});3. Handle Loading States
typescript
const { isLoading, isFetching, data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
// isLoading: First load
// isFetching: Background refetch
// data: Cached data available4. Use Suspense
typescript
// Enable Suspense mode
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
});
// Use with Suspense
function App() {
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={<div>Loading...</div>}>
<Users />
</Suspense>
</QueryClientProvider>
);
}5. Mutate with Proper Error Handling
typescript
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
toast.success('Post created!');
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: (error) => {
toast.error(`Error: ${error.message}`);
},
});Conclusion
React Query transforms server state management. Use it for:
- API data fetching
- Caching and synchronization
- Background updates
- Optimistic updates
Master queries, mutations, and infinite scrolling for powerful data fetching. The result is fast, responsive UIs with minimal boilerplate.