State Management: Server-Side with React Query
Server state is different from UI state. UI state lives in your component (e.g., "is the dropdown open?"). Server state lives on the backend (e.g., "what's the user's billing status?"). Mixing them causes bugs: out-of-sync data, stale caches, and unexpected re-renders. React Query solves this by treating the server as the source of truth and syncing the client cache.
I spent a week debugging a SaaS dashboard where a user's plan changed on the server, but their sidebar didn't update because we were using Redux for server state. Switching to React Query cut our state-management code by 60% and eliminated the sync bugs. This article teaches you the right patterns.
What You'll Learn
You'll build:
- Queries for fetching server data with automatic caching
- Mutations for POST/PUT/DELETE operations that update the server
- Optimistic updates (show results before the server confirms)
- Cache invalidation (force a refetch when data changes)
- Error handling and rollback on failed mutations
Prerequisites
You need React Query installed from the previous article. Understanding of Promise-based APIs and HTTP methods (GET, POST, PUT, DELETE) is assumed.
Step 1: Understand Query vs. Mutation
A useQuery fetches read-only data and caches it. A useMutation sends changes to the server. They're separate for a reason:
| Feature | useQuery | useMutation |
|---|---|---|
| Purpose | Fetch (GET) | Modify (POST/PUT/DELETE) |
| Caching | Automatic, multi-use | None (one-off operation) |
| Refetch behavior | Automatic on stale | Manual or on cache invalidation |
| Multiple calls | Deduplicated | Sent immediately |
Step 2: Create a Custom Mutation Hook
Create src/hooks/useUpdateUser.ts:
import { useMutation, useQueryClient } from '@tanstack/react-query';
export interface UpdateUserPayload {
name?: string;
email?: string;
role?: string;
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: UpdateUserPayload & { id: string }) => {
const { id, ...data } = payload;
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to update user');
}
return response.json();
},
onSuccess: (data) => {
// Invalidate the users list so it refetches
queryClient.invalidateQueries({ queryKey: ['users'] });
// Optionally update a single user query if you have one
queryClient.setQueryData(['user', data.id], data);
},
onError: (error) => {
console.error('Update failed:', error);
},
});
}
queryClient.invalidateQueries() tells React Query that the users query is stale and should refetch on next use. setQueryData updates the cache directly without a network request.
Step 3: Use the Mutation in a Form
Create src/components/EditUserModal.tsx:
import { useState } from 'react';
import { useUpdateUser } from '../hooks/useUpdateUser';
interface EditUserModalProps {
userId: string;
userName: string;
userEmail: string;
onClose: () => void;
}
export function EditUserModal({
userId,
userName,
userEmail,
onClose,
}: EditUserModalProps) {
const [name, setName] = useState(userName);
const [email, setEmail] = useState(userEmail);
const { mutate, isPending, error } = useUpdateUser();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutate(
{ id: userId, name, email },
{
onSuccess: () => {
onClose();
},
}
);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Edit User</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
{error && (
<p className="text-red-600 text-sm">{(error as Error).message}</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-gray-300 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{isPending ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}
isPending shows a loading state. The mutation automatically invalidates the users list on success.
Step 4: Implement Optimistic Updates
Optimistic updates show results immediately, then roll back if the server rejects. Create src/hooks/useDeleteUser.ts:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { User } from '../hooks/useUsers';
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userId: string) => {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete user');
return userId;
},
onMutate: async (userId: string) => {
// Cancel in-flight queries to prevent overwriting optimistic update
await queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot the current data
const previousUsers = queryClient.getQueryData<User[]>(['users']);
// Optimistically remove the user from cache
queryClient.setQueryData(['users'], (old: User[] | undefined) =>
old ? old.filter((u) => u.id !== userId) : []
);
// Return snapshot so we can rollback on error
return { previousUsers };
},
onError: (error, userId, context) => {
// Rollback to snapshot on error
if (context?.previousUsers) {
queryClient.setQueryData(['users'], context.previousUsers);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
The onMutate hook runs immediately, removing the user from the UI. If the server rejects, onError restores the snapshot. The user never sees a flicker.
Step 5: Handle Error Scenarios
Always plan for failures. In a delete button component:
import { useDeleteUser } from '../hooks/useDeleteUser';
export function DeleteUserButton({ userId, userName }: { userId: string; userName: string }) {
const { mutate, isPending, error } = useDeleteUser();
const handleDelete = () => {
if (confirm(`Delete ${userName}? This cannot be undone.`)) {
mutate(userId);
}
};
return (
<>
<button
onClick={handleDelete}
disabled={isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
{error && (
<p className="text-red-600 text-sm">
{(error as Error).message}. Changes were rolled back.
</p>
)}
</>
);
}
Confirmations prevent accidental deletes. Error messages explain what went wrong.
When to Refetch vs. Update the Cache
| Scenario | Approach | Why |
|---|---|---|
| Add item to list | Invalidate query | New item might need server-side processing (IDs) |
| Delete item | Optimistic update (remove from cache) | You know exactly what to remove |
| Edit item | Set cache + invalidate | Fast UI response + confirmation from server |
| Toggle boolean | Optimistic update | Simple flip, easy to rollback |
Key Takeaways
- React Query separates server state (queries) from mutations (writes), preventing the sync bugs that plague Redux + server-data patterns.
- Invalidating a query forces a refetch; use it when you're unsure what changed on the server.
- Optimistic updates make UIs feel faster by showing changes immediately and rolling back only on error.
- Always handle errors: show a message, rollback state, and offer retry options.
queryClientis your gateway to cache manipulation—learn its API well.
Frequently Asked Questions
What's the difference between invalidateQueries and setQueryData?
invalidateQueries marks a query as stale and will refetch on next use or immediately. setQueryData updates the cache directly without a network request. Use invalidateQueries when the server made changes you don't know, and setQueryData when you received the updated data in the mutation response.
Can I share mutations across components?
Yes, that's why you extract them into custom hooks. useUpdateUser can be imported anywhere and doesn't duplicate the mutation logic.
What if two users edit the same record simultaneously?
React Query can't solve this alone—it's a backend problem (optimistic locking, versioning, or operational transforms). The best approach: on mutation error, refetch and show the latest version to the user, prompting them to re-edit.
How do I paginate without losing cache?
React Query caches each page separately (based on the queryKey including page). If you need all pages cached, you'd fetch and combine them. Most SaaS dashboards paginate the server-side, so this isn't an issue.
Can I use React Query for WebSocket data?
Yes, use setQueryData in your WebSocket message handler to update the cache, and components re-render. Example: queryClient.setQueryData(['live-stats'], newData).