Letting the ducks fly: How we’re switching from Redux to React Query


An incremental journey from Redux modules to intuitive async data management


Years ago we adopted Redux to manage the client state, handle the local state, and control data fetching. More recently, we began transitioning to React Query. Redux is primarily designed as a synchronous state manager, while React Query specializes in managing the asynchronous state.

Both are agnostic to the underlying fetching library, but Redux requires significant boilerplate to handle async operations, whereas React Query offers built-in patterns that make data fetching, caching, and updating feel more natural. We decided these advantages made React Query a better fit for our codebase.

Depending on the size of the application, migrating to React Query can require a significant amount of time. In our case, rather than setting a strict deadline, we are treating the migration as an ongoing effort over a year or so as we gradually replace the asynchronous Redux logic with React Query in the flow of continuous integration.




1. A compelling library

In Redux, managing server data involves manually updating state through actions and reducers, which can be cumbersome and repetitive, especially when dealing with asynchronous API calls. Because Redux is a synchronous state library, handling asynchronous behavior requires middleware like redux-thunk, adding even more boilerplate to the process.

State synchronization and caching are hard problems in their own right, especially when they involve keeping frontend and backend data in sync. React Query simplifies this process by automatically caching server data and offering built-in tools for refreshing and invalidating queries.

Instead of handling complex state updates, React Query’s staleTime and gcTime settings handle caching and data refreshing automatically; queries are invalidated when data might be outdated, and refreshed only when necessary. This lets React Query act as a specialized cache for server-state, which frees us from having to manually synchronize state with the API and allows us to keep our API as the single source of truth.

Therefore, we avoid manual data updates and instead use React Query’s default caching setup to stay synchronized with the server. For most queries, we use:

  • staleTime: Set to 0ms (default), so that data is always considered stale and automatically refetched on each rerender, along with other situations. Some are controlled by configuration, others handled by default. (We sometimes make an exception here – see further down)

  • gcTime (formerly cacheTime in V4): Set to 5 minutes (default). gcTime stands for garbage collection time. It is a countdown that starts once the query becomes inactive (e.g., when the component unmounts or the app goes to the background), after which the data is removed from memory. cacheTime was a misleading name for a countdown, which is why, in React Query v5, it was renamed gcTime to better reflect its role.

This approach lets us keep our code light. Rather than tracking and updating cache manually as in Redux, we simply invalidate queries when needed, and React Query handles refetching and cache expiration for us.

For example, after successfully uploading a document with putDocuments, we invalidate the ['documents'] query and React Query refreshes the document list automatically.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getDocuments, putDocuments } from './api'

const useDocuments = () => {
  return useQuery({ queryKey: ['documents'], queryFn: getDocuments })
}

const useUploadDocument = () => {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: putDocuments,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['documents'] })
    },
  })
}

const Documents = () => {
  const { data: documents } = useDocuments()
  const { mutate: uploadDocument } = useUploadDocument()

  return (
    <>
      
    {documents?.map(doc => (
  • {doc.name}
  • ))}
> ) }
Enter fullscreen mode

Exit fullscreen mode




2. Rethinking loading / error logic

React Query makes working with asynchronous data feel seamless. Because data fetching, caching, and synchronization are built into its core, handling loading or error states becomes part of the natural flow.

We choose between 3 main solutions to deal with a loading state:

  • Let the component render (with no data) and re-render naturally when data is available. If the data being fetched is low priority and has little impact on the main content, we tend to just let the component render. The missing data will appear on the page once the content has been successfully received.
const useGetDocumentsQuery = () => {
  const {
    data: documents,
    isLoading: isLaodingDocuments,
    isError: isDocumentsError
  } = useQuery({
    queryKey: DOCUMENTS_QUERY_KEY,
    queryFn: async () => {
      const response = await get("/pro/documents");
      return response.data;
    },
  });

  return { documents, isLaodingDocuments, isDocumentsError };
};

const DocumentPage = () => {
  const { documents } = useGetDocumentsQuery();

  return (
    <>
      Documents
      {documents && 

You have {documents.length} documents

} > ); };
Enter fullscreen mode

Exit fullscreen mode

  • Return null as long as the data isn’t ready (your screen will remain blank in the meantime). In most cases, when loading time is less than 300ms, we choose to return null while data is fetching and let the page render once the component has everything it needs.
const HomePage = () => {
  const { documents, isLoadingDocuments, isDocumentsError } = useGetDocumentsQuery();
  if (isLoadingDocuments) return null;
  if (isDocumentsError)
    return 

Error handling here.

; return <>HomePage content>; };
Enter fullscreen mode

Exit fullscreen mode

  • Display a loader (spinner, skeleton, progress bar, etc.). If the page relies heavily on the data and fetching takes time, we like to let the user know that something is cooking up! A loader is quick to implement and a shimmer effect often makes the experience feel smoother.
const HomePage = () => {
  const { documents } = useGetDocumentsQuery();
  if (!documents) return ;

  return (
    <>
      
    >
  );
};

Enter fullscreen mode

Exit fullscreen mode

Side note: in the example above, we check !documents instead of isLoadingDocuments so that documents can’t be undefined in ChildComponent. This ensures that by the time ChildComponent is rendered, documents is always defined. In TypeScript, this narrows the type so we don’t have to handle documents being undefined inside ChildComponent.




3. How we got started

In our codebase, we usually have one duck per API resource. In Redux, a duck is simply a module that organizes everything related to one feature (its action types, action creators, and reducer) into a single file. Most of our ducks are independent and can be migrated one by one, allowing PRs to be kept small and comprehensible.

  1. After identifying which duck to migrate, we review the API calls handled by its action functions. For each API resource, we create a dedicated custom hook to handle that request. These custom hooks encapsulate the React Query logic and manage the state related to that request. We consider this a good practice since it avoids duplicating request-related code and provides a clear, reusable interface for consuming components.

  2. Next, we focus on the highest-level component in the stack that consumes the data, since this is usually where most of the work is required. Instead of deleting the existing Redux code right away, we often comment it out. This makes it easier to refer back to during the migration, as parts of the logic can still be reused.

  3. From there, the process is pretty straightforward: we plan for what should happen while the data is loading, once it’s available, and if an error occurs. React Query provides a rich set of status flags that let us adapt our component’s logic.




4. Challenges & corner cases

We have encountered a few bumps along the road to implementing React Query…

Testing
Testing our components was the first roadblock that we hit. We believe in integration testing and wanted to keep it up with the components that now use React Query.

We ended up using msw (mock service worker) which is a middleware that intercepts real API calls and returns a mocked response. Our configuration lets us mock all API endpoints at once in a dedicated file, so that we don’t need to mock for each individual test case. For specific test cases, we can still override individual responses directly within the test.

Compared to Redux, where we mocked the API layer extensively, this approach feels closer to real-world behavior and it gave us greater confidence in how our components interact with the API.

gcTime
We also encountered challenges in specific cases with staleTime and gcTime due to the default staleTime being set to 0, which sometimes triggered redundant API calls. This can occur when multiple components using the same query mount nearly simultaneously but with a slight delay of just a few milliseconds. React Query treats the data as stale for each mount, causing multiple requests. Setting a short staleTime of 1 second was enough to prevent this.

Some dispatch functions can overlap between ducks.
In a few cases, ducks in our codebase call dispatch functions from another ducks. Normally, ducks handle a single API resource, but we occasionally violated this rule, creating cross-duck side effects. During migration, we move such logic into a temporary regular function so the other duck can keep working; it can be removed once the second duck is migrated.

In these cases, if the temporary function mutates data, make sure to invalidate the React Query cache for any affected API resources.




Conclusion

Switching from Redux to React Query has greatly simplified the way we manage server state. Redux works well for the synchronous state, but asynchronous workflows require middleware like redux-thunk and extra boilerplate. React Query was built for that purpose, with caching, query invalidation, and automatic refetching, making it easier to keep the API as the single source of truth while improving maintainability.

I hope this article inspires you to explore React Query or refine your own migration strategies. Let us know your thoughts, challenges, or tips in the comments!



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *