Introduction
React applications often fetch data from APIs and then store it in component state. As an application grows, the same data may be requested by several components, refreshed inconsistently, or accidentally overwritten by stale responses.
The key distinction is between server state and client state. Server state is owned by an external system; client state represents local interface decisions such as an open menu or an unsaved input.
1. Avoid unnecessary copies of API data
Consider a component that fetches products and copies the response into several unrelated state variables. Every refresh then requires coordinating those copies.
Instead, establish one clear owner for fetched data. A data-fetching library can manage request status, caching, deduplication, and refetching behavior.
For example, with TanStack Query:
import { useQuery } from "@tanstack/react-query";
function Products({ api }) {
const query = useQuery({
queryKey: ["products"],
queryFn: () => api.getProducts(),
});
if (query.isPending) {
return <p>Loading products…</p>;
}
if (query.isError) {
return <p>Could not load products.</p>;
}
return (
<ul>
{query.data.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
This assumes the application has configured a QueryClientProvider and that api.getProducts() returns a promise resolving to an array of products.
2. Choose meaningful query keys
A query key identifies the data being requested. If results depend on filters, search terms, or a user-selected page, those inputs should be reflected in the key.
useQuery({
queryKey: ["products", { category, page }],
queryFn: () => api.getProducts({ category, page }),
});
A complete key helps the library distinguish one result set from another. Avoid keys that omit parameters affecting the response.
3. Invalidate after mutations
After a product is updated, cached product data may be stale. With TanStack Query, a successful mutation can invalidate relevant queries so they are refetched when appropriate.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useUpdateProduct(api) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (product) => api.updateProduct(product),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["products"],
});
},
});
}
This is a basic invalidation strategy. Applications with many query types should identify which caches are affected by each mutation rather than invalidating everything indiscriminately.
4. Keep local UI state local
Not all state belongs in a server-state library. A dialog’s open state, temporary form values, and selected tab are often best managed with React state or a dedicated client-state solution.
Avoid storing the same value in multiple places unless there is a clear reason. Duplicated state creates synchronization problems.
5. Plan for loading, errors, and stale data
A robust interface should decide what to show during initial loading, background refetching, and failure. In some cases, retaining the previous data while a refresh runs creates a better experience than replacing the entire screen with a spinner.
Also consider authentication boundaries. Cached user-specific data must not be accidentally reused after logout or when a different user signs in.
Conclusion
Server-state management becomes easier when API data has a clear owner, query keys describe the request, and mutations update or invalidate the correct caches. Keep transient interface state separate and design explicitly for loading, errors, and user changes.