Managing server state in React becomes challenging as applications grow. Fetching data, handling loading states, synchronizing updates, preventing duplicate requests, and keeping cached information fresh can quickly make components difficult to maintain.
TanStack Query, formerly known as React Query, simplifies these challenges by providing caching, background refetching, request deduplication, and mutation management out of the box. Instead of manually coordinating API calls with useEffect and useState, developers can focus on building reliable user experiences.
However, using React Query effectively requires more than wrapping an API request in useQuery. Understanding query keys, cache configuration, error handling, mutations, and invalidation strategies helps prevent subtle bugs and improves application performance.
8 React Query Best Practices Every Developer Should Follow
1. Create a Shared QueryClient
Initialize QueryClient once at the application level and provide it through QueryClientProvider. Creating a new client during every render can reset caches and undermine the benefits of server-state management. Configure sensible defaults centrally while allowing individual queries to override them when necessary.
2. Use Consistent and Predictable Query Keys
Query keys identify cached data and determine which queries should be updated or invalidated. Use arrays containing meaningful identifiers and filters, such as ["users", userId] or ["products", { category, page }]. Keep a consistent naming convention so related queries can be invalidated together without accidentally refreshing unrelated data.
3. Separate API Logic from Components
Keep HTTP requests in reusable API functions instead of embedding fetch logic inside every component. This makes request handling easier to test, reuse, and maintain. Your query functions should return parsed data and throw meaningful errors when the response is unsuccessful, because React Query relies on rejected promises to identify failed requests.
4. Configure staleTime and gcTime Intentionally
staleTime determines how long cached data is considered fresh, while gcTime controls how long inactive cached queries remain in memory. A longer staleTime can reduce unnecessary requests for data that rarely changes. Use shorter freshness windows for frequently changing information, and remember that stale data can remain visible while a background refetch occurs.
5. Use Mutations and Invalidation for Updates
Use useMutation for operations that create, update, or delete server-side data. After a successful mutation, invalidate the relevant query keys so affected screens can refresh their data. For interactions that require immediate feedback, consider optimistic updates, but implement rollback and cache reconciliation carefully.
6. Handle Loading, Empty, and Error States
A reliable interface distinguishes between initial loading, background fetching, empty results, and failed requests. Use isPending for the initial pending state, isFetching to identify ongoing fetch activity, and isError to render an appropriate error message. This makes the application easier to understand and prevents a failed request from being mistaken for an empty result.
7. Avoid Unnecessary Refetching
TanStack Query can refetch data when a component mounts, a window regains focus, or a network connection is restored. These behaviors are useful for keeping data fresh, but they should match your application's requirements. Configure refetchOnWindowFocus, retry, and other options deliberately instead of disabling every automatic behavior globally.
8. Keep Queries Independent and Testable
Build custom hooks around recurring data-access patterns, such as useUsers or useProducts. Keep query keys and API functions easy to locate, and test success, failure, and invalidation behavior. For dependent requests, use the enabled option when a required identifier is not yet available rather than executing an incomplete request.
import React from "react";
import { createRoot } from "react-dom/client";
import {
QueryClient,
QueryClientProvider,
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
// Step 1: Create a shared client outside the React component tree.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // Keep successful data fresh for one minute.
gcTime: 5 * 60_000, // Retain inactive cache entries for five minutes.
retry: 1, // Retry a failed query once.
refetchOnWindowFocus: false,
},
},
});
// Step 2: Define stable query keys in one place.
const userKeys = {
all: ["users"],
lists: () => [...userKeys.all, "list"],
};
// Step 3: Keep API requests separate from UI components.
async function fetchUsers() {
console.log("Step 3: Requesting users from the API...");
const response = await fetch("https://jsonplaceholder.typicode.com/users");
// fetch does not reject automatically for HTTP 4xx or 5xx responses.
if (!response.ok) {
throw new Error(`Unable to fetch users: HTTP ${response.status}`);
}
const users = await response.json();
console.log(`Received ${users.length} users successfully.`);
return users;
}
// Step 4: Create a reusable hook for server-state access.
function useUsers() {
return useQuery({
queryKey: userKeys.lists(),
queryFn: fetchUsers,
});
}
// Step 5: Define a mutation and invalidate affected cached queries.
async function createUser(newUser) {
console.log("Step 5: Sending a create-user request...");
const response = await fetch(
"https://jsonplaceholder.typicode.com/users",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newUser),
}
);
if (!response.ok) {
throw new Error(`Unable to create user: HTTP ${response.status}`);
}
return response.json();
}
function UsersPage() {
const queryClient = useQueryClient();
const { data: users, isPending, isError, error, isFetching } = useUsers();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: async () => {
console.log("Step 6: Mutation succeeded; invalidating user lists.");
await queryClient.invalidateQueries({ queryKey: userKeys.all });
console.log("User list cache has been marked stale and refreshed when active.");
},
onError: (mutationError) => {
console.error("Create-user mutation failed:", mutationError.message);
},
});
// Step 7: Render an explicit initial loading state.
if (isPending) {
return <p>Loading users...</p>;
}
// Step 8: Keep errors distinct from successful empty results.
if (isError) {
return <p role="alert">Could not load users: {error.message}</p>;
}
function handleCreateUser() {
console.log("Step 9: Preparing a sample user mutation.");
mutation.mutate({
name: `Demo User ${Date.now()}`,
username: `demo${Date.now()}`,
email: `demo${Date.now()}@example.com`,
});
}
return (
<main>
<h1>Users</h1>
{isFetching && <p>Refreshing users in the background...</p>}
{users.length === 0 ? (
<p>No users found.</p>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
)}
<button onClick={handleCreateUser} disabled={mutation.isPending}>
{mutation.isPending ? "Creating user..." : "Create sample user"}
</button>
{mutation.isError && (
<p role="alert">Creation failed: {mutation.error.message}</p>
)}
{mutation.isSuccess && <p>User creation request completed.</p>}
</main>
);
}
// Step 10: Provide the same QueryClient to the entire application.
function App() {
return (
<QueryClientProvider client={queryClient}>
<UsersPage />
</QueryClientProvider>
);
}
// Step 11: Mount the application in the browser.
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Missing #root element in index.html");
}
console.log("Starting the React Query best-practices demo...");
createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Note: This example requires React, React DOM, and @tanstack/react-query.
// The JSONPlaceholder POST endpoint simulates creation; it does not persist users.
Conclusion
React Query works best when server-state management follows a clear and consistent architecture. Shared query keys, reusable API functions, intentional cache settings, and mutation-driven invalidation make applications easier to maintain while reducing unnecessary network requests.
Start with a shared QueryClient and a small set of custom query hooks, then introduce more advanced techniques such as optimistic updates, pagination, and dependent queries as your application grows. Avoid adding configuration simply because it exists; every cache and refetch option should serve a specific requirement.
Most importantly, treat loading states, errors, and cache consistency as core parts of the user experience rather than implementation details. When these practices are applied consistently, TanStack Query becomes a dependable foundation for building responsive, scalable React applications.
Top comments (0)