Mastering React Query: The Ultimate Guide to Server State Management
Introduction
React Query (now TanStack Query) has become an essential library for managing server state in modern React applications. Unlike client state (like form inputs or UI toggles), server state has unique characteristics: it's asynchronous, can become stale, and is shared across components. React Query handles these complexities elegantly, making data fetching feel almost magical.
To grasp the idea in a glance, have a look to this component and its annotation : https://github.com/HPWebdeveloper/react-query/blob/main/src/components/FetchDataReactQuery.tsx
Why React Query?
Traditional data fetching in React involves managing loading states, error handling, and caching manually. This leads to boilerplate code scattered throughout your application. React Query abstracts these concerns and provides:
- Automatic caching - Data is cached by default and shared across components
- Background refetching - Keep data fresh automatically
- Optimistic updates - Update UI immediately before server confirms
- Request deduplication - Multiple components requesting the same data trigger only one request
- Pagination & infinite scrolling - Built-in support with simple APIs
Getting Started
First, install React Query:
npm install @tanstack/react-query
Set up the QueryClient and Provider in your app root:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
Basic Data Fetching with useQuery
The useQuery hook is your primary tool for fetching data:
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
)
}
The queryKey is crucial - it uniquely identifies this query for caching. When the key changes (e.g., different userId), React Query automatically refetches.
Mutations with useMutation
For creating, updating, or deleting data, use useMutation:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function EditUser({ userId }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (updatedUser) => {
return fetch(`/api/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(updatedUser),
headers: { 'Content-Type': 'application/json' },
})
},
onSuccess: () => {
// Invalidate and refetch user data
queryClient.invalidateQueries({ queryKey: ['user', userId] })
},
})
const handleSubmit = (formData) => {
mutation.mutate(formData)
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
</form>
)
}
Advanced Patterns
Optimistic Updates
Update the UI immediately before the server responds:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistically update
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
Pagination
Handle paginated data elegantly:
function Users() {
const [page, setPage] = useState(1)
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
placeholderData: keepPreviousData,
})
return (
<div>
{data.users.map((user) => (
<UserCard key={user.id} user={user} />
))}
<button onClick={() => setPage((old) => old - 1)} disabled={page === 1}>
Previous
</button>
<button
onClick={() => setPage((old) => old + 1)}
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button>
</div>
)
}
Dependent Queries
Execute queries in sequence when one depends on another:
function UserPosts({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user.id),
enabled: !!user?.id, // Only run when user is loaded
})
return <PostsList posts={posts} />
}
Best Practices
1. Structure Your Query Keys
Use arrays with hierarchical structure:
;['users'][('users', userId)][('users', userId, 'posts')][ // All users // Specific user // User's posts
('users', userId, 'posts', { status: 'published' })
] // Filtered posts
2. Configure Stale Time and Cache Time
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 10, // 10 minutes
},
},
})
3. Use React Query DevTools
Install the DevTools for debugging:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
;<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
Performance Optimization
Prefetching
Load data before it's needed:
const queryClient = useQueryClient()
const prefetchUser = (userId) => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
}
// On hover or route change
;<Link onMouseEnter={() => prefetchUser(userId)}>View Profile</Link>
Selective Rendering
Use select to transform data and prevent unnecessary re-renders:
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
select: (user) => user.name, // Only re-render when name changes
})
Conclusion
React Query transforms how we handle server state in React applications. By embracing its conventions and patterns, you'll write less code, handle edge cases better, and create more performant applications. The library's intelligent caching and background synchronization mean users see fast, up-to-date data without you managing complex state logic.
Start with useQuery for fetching and useMutation for updates, then gradually adopt advanced patterns like optimistic updates and prefetching as your application grows. React Query's excellent TypeScript support and comprehensive documentation make it a joy to work with.
Resources
Happy querying! 🚀