Introduction: The Shift from Legacy Data Fetching
For years, React developers relied on patterns like useState + useEffect,
Redux with thunks or sagas, MobX actions, or even class-based lifecycle methods to manage
asynchronous server state. These approaches work but introduce significant boilerplate,
inconsistent caching, and error-prone manual state synchronization. As applications grow,
the complexity of keeping the UI in sync with the server becomes a maintenance burden.
React Query (RQ) – now part of the TanStack ecosystem – offers a declarative, performant solution for server-state management. Migrating from a legacy framework to RQ streamlines data fetching, caching, and synchronization, allowing you to focus on business logic rather than plumbing. This tutorial walks through the migration process, from assessment to full integration, with practical code examples and best practices.
What is React Query (RQ)?
React Query is a library that treats server state as a distinct concern from
client state. It provides hooks like useQuery and useMutation
that handle fetching, caching, background refetching, and updating of remote data.
Under the hood, RQ maintains a normalized cache, deduplicates requests, retries on failure,
and automatically re-fetches when windows regain focus or when the user navigates back
to a page. The library is framework-agnostic (React, Solid, Vue, Svelte) but we'll focus on
React in this tutorial.
Why Migrate to React Query?
- Reduces boilerplate: No more manual loading/error states spread across multiple components.
- Automatic caching and deduplication: The same query key only triggers one network call, and cached data is instantly available.
- Background refetching: Keeps data fresh without blocking the UI with spinners.
- Optimistic updates and rollbacks: Built-in mutation helpers provide a snappy user experience.
- Separation of concerns: Server state lives in the RQ cache, while local UI state stays in
useStateor a lightweight state manager. - Devtools: A dedicated visual interface to inspect queries, mutations, and cache state in real time.
Step-by-Step Migration Guide
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →1. Assess Your Current Data Fetching Patterns
Begin by scanning your codebase for places where server data is fetched or mutated. Common legacy patterns include:
- Components with
useEffectcontainingfetch()oraxios.get(). - Redux slices using
createAsyncThunkor saga-based fetching. - Class components using
componentDidMountandcomponentDidUpdate. - Custom hooks that manage loading, error, and data states with multiple
useStatecalls.
Identify the API endpoints, the shape of the data, and how it's consumed in the UI. This inventory will guide the replacement process.
2. Install and Set Up React Query
npm install @tanstack/react-query
Wrap your application (or a subtree) with the QueryClientProvider.
A QueryClient instance holds the cache and configuration.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes before data becomes stale
retry: 2, // retry twice on failure
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourRouter />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
This single setup replaces the need for any custom "loading/error/data" state orchestration that previously lived in Redux middleware or custom providers.
3. Replace a Simple Fetch with useQuery
Consider a typical legacy component fetching a list of todos:
// Legacy approach with useState + useEffect
function TodoListLegacy() {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/todos')
.then(res => {
if (!res.ok) throw new Error('Network error');
return res.json();
})
.then(data => {
setTodos(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{todos.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
);
}
With React Query, the same component becomes drastically simpler. The hook manages loading, error, and data states automatically, and provides additional features like background refetching.
import { useQuery } from '@tanstack/react-query';
function TodoList() {
const { data: todos, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(res => {
if (!res.ok) throw new Error('Network error');
return res.json();
}),
staleTime: 1000 * 60 * 5, // 5 min
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{todos.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
);
}
Notice the removal of manual state management. The query key ['todos']
uniquely identifies this resource across the cache. Multiple components using
the same key share data without extra code.
4. Migrate Mutations (POST, PUT, DELETE)
Legacy mutation handling often involved dispatching Redux actions or manually
toggling a loading flag. RQ provides the useMutation hook,
which encapsulates the mutation lifecycle and integrates with the query cache
to keep data consistent.
Legacy pattern (Redux thunk example):
// Redux slice
const addTodo = createAsyncThunk('todos/add', async (newTodo) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
return response.json();
});
// In component: dispatch(addTodo({ title: 'Buy milk' }))
// and handle pending/fulfilled via selectors
Migration to useMutation:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddTodoForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) =>
fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
}).then(res => res.json()),
onSuccess: (addedTodo) => {
// Invalidate the 'todos' query to refetch the updated list
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Or manually update cache for immediate UI feedback
queryClient.setQueryData(['todos'], (oldTodos) =>
oldTodos ? [...oldTodos, addedTodo] : [addedTodo]
);
},
onError: (error) => {
console.error('Failed to add todo:', error);
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutation.mutate({ title: e.target.title.value });
};
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<button type="submit" disabled={mutation.isLoading}>
{mutation.isLoading ? 'Adding...' : 'Add'}
</button>
{mutation.error && <p>Error: {mutation.error.message}</p>}
</form>
);
}
The mutation hook provides isLoading, error, and callbacks
without any global store wiring. By using invalidateQueries or
setQueryData, the cache stays coherent and dependent components re-render
automatically.
5. Handle Complex State: Replacing Redux or MobX
If your legacy app uses Redux (or MobX) primarily for server state, you can
gradually eliminate those slices. React Query’s cache is the new single source
of truth for remote data. UI-only state (form values, modal visibility, etc.)
can remain in useState or a minimal context.
Example: Removing a Redux slice for user profile
// Before: Redux slice
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null },
reducers: { /* ... */ },
extraReducers: (builder) => {
builder.addCase(fetchUser.pending, (state) => { state.loading = true; });
builder.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
});
builder.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
After migration, the slice disappears entirely:
function useUserProfile(userId) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
enabled: !!userId, // don't fetch until userId is available
});
}
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useUserProfile(userId);
if (isLoading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Welcome, {user.name}</div>;
}
The same principle applies to collections, paginated lists, and any remote resource. Gradually retire Redux reducers that merely mirror server responses.
6. Pagination, Infinite Queries, and Dependent Queries
Legacy code often implements pagination with manual page tracking and
concatenation logic. React Query provides keepPreviousData
for smooth page transitions and useInfiniteQuery for
"load more" patterns.
Paginated query example:
function usePaginatedTodos(page) {
return useQuery({
queryKey: ['todos', { page }],
queryFn: () => fetch(`/api/todos?page=${page}`).then(res => res.json()),
keepPreviousData: true, // keep showing previous page while next loads
staleTime: 1000 * 60 * 2,
});
}
Infinite query (cursor-based):
import { useInfiniteQuery } from '@tanstack/react-query';
function useInfiniteTodos() {
return useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/todos?cursor=${pageParam}`).then(res => res.json()),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
initialPageParam: 0,
});
}
Dependent queries (where one fetch depends on another) are handled with the
enabled option, replacing complex promise chains or
nested effects.
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
const { data: permissions } = useQuery({
queryKey: ['permissions', user?.role],
queryFn: () => fetch(`/api/permissions/${user.role}`),
enabled: !!user, // only run when user is available
});
7. Error Handling and Retries
RQ retries failed queries automatically (default 3 times) with exponential backoff.
You can customize retry logic per query or globally. For legacy error boundaries,
you can integrate RQ’s onError callbacks or use the useQueryErrorResetBoundary
hook to clear errors when retrying.
// Per-query retry config
useQuery({
queryKey: ['critical-data'],
queryFn: fetchCriticalData,
retry: 5,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});
// Global default via QueryClient
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
},
},
});
Best Practices for Migration
- Incremental adoption: Replace one data-fetching component or Redux slice at a time. Keep both RQ and legacy code running side-by-side until you’re confident.
- Keep UI state local: Use
useStateoruseReducerfor form inputs, modal toggles, and purely client-side concerns. Do not migrate these into RQ. - Structure query keys thoughtfully: Use arrays like
['todos', 'list', { filter: 'active' }]. This enables granular cache invalidation and avoids key collisions. - Set
staleTimeandgcTime:staleTimecontrols how long data is considered fresh (no background refetch), whilegcTime(formerlycacheTime) determines how long inactive data stays in the cache. Tune these based on your data's volatility. - Leverage the Devtools: The
ReactQueryDevtoolspanel shows all queries, their status, and cache data. It’s invaluable during migration to ensure queries are set up correctly. - Test error and loading states: Verify that components handle the
isLoading,isError, anddatastates properly. Use React Query’sinitialDataorplaceholderDatafor SSR or initial render. - Avoid prop drilling for query results: Components can call
useQuerywith the same key directly – RQ deduplicates requests, so no need to pass data down through layers.
Conclusion
Migrating from legacy frameworks like Redux, MobX, or custom useEffect
patterns to React Query transforms the way you manage server state. The declarative API
eliminates boilerplate, improves performance through intelligent caching, and
provides a robust foundation for mutations and optimistic updates. By following
an incremental strategy – assessing existing patterns, setting up the provider,
replacing fetches with useQuery, and integrating mutations – you
can modernize your React application without a full rewrite. Embrace the separation
of server and client state, leverage the devtools, and enjoy a codebase that is
easier to maintain and scale.