← Back to DevBytes

Migrating from Legacy Frameworks to RQ

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?

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles