Why I stopped using React's useState for server state management
useState seems natural for API data, but it creates stale state bugs and makes caching impossible.
Why I stopped using React's useState for server state management
I used to manage all my API data with useState. It felt straightforward -- fetch the data, store it in state, render it in the component. The pattern was simple: call the API in a useEffect, set loading to true, then update state with the response. Most React tutorials taught this approach, and it worked perfectly for basic examples. Then I built a real application where users could navigate between pages, refresh data, and expect the app to remember what they had already loaded.
The breaking point came when I noticed the same API calls firing repeatedly as users navigated through the app. A user would view a profile page, navigate to settings, then return to the profile -- triggering the exact same API call three times. I had useState scattered across dozens of components, each managing its own piece of server data with no coordination between them. When I needed to update a user's name after they edited their profile, I found myself manually finding and updating state in multiple components that displayed that same user data.
That's when I learned that server state is fundamentally different from client state. Server state represents a cached copy of data that lives somewhere else. It can become stale, needs to be synchronized, and should be shared across components that need the same data. Client state like form inputs or UI toggles belongs to a specific component and changes based on user interactions.
The hidden complexity of server state
Server state has requirements that useState simply was not designed to handle:
- Background refetching -- Data should refresh automatically when the window regains focus
- Deduplication -- Multiple components requesting the same data should not trigger duplicate requests
- Cache invalidation -- When data changes, all components displaying that data need to update
- Optimistic updates -- UI should update immediately while mutations are pending
- Error boundaries -- Failed requests should not crash unrelated parts of the app
Here's what my old approach looked like:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
try {
const response = await api.getUser(userId);
setUser(response.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
// Component renders...
}This worked until I needed the same user data in a header component, a sidebar, and a settings page. Each component would fetch the data independently, creating duplicate requests and inconsistent loading states.
What I do instead
Now I use React Query (TanStack Query) for all server state management. It handles caching, deduplication, background updates, and error states automatically:
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => api.getUser(userId),
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
// Component renders with user data...
}The same query in any other component automatically shares the cached result. When I update user data, I can invalidate the cache globally:
function UpdateUserForm({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (userData) => api.updateUser(userId, userData),
onSuccess: () => {
// Automatically refetch user data everywhere
queryClient.invalidateQueries(['user', userId]);
},
});
// Form submission logic...
}The benefits I gained
Switching from useState to React Query for server state eliminated entire categories of bugs:
- No more duplicate requests -- React Query deduplicates identical queries automatically
- Consistent loading states -- All components share the same loading state for the same data
- Automatic error recovery -- Failed queries retry with exponential backoff
- Better user experience -- Data refreshes in the background when the user returns to the app
- Simpler components -- No more manual loading/error state management
The most surprising benefit was how much simpler my components became. I went from 15-20 lines of state management boilerplate to 3-5 lines that expressed exactly what data the component needed.
My components are now more focused on rendering and user interactions instead of managing the complexities of server state synchronization. React Query handles the hard parts -- caching, deduplication, background updates, and error recovery -- while my components focus on displaying data and responding to user actions.
Server state management is a solved problem. Do not reinvent it with useState.