Adopting React Query at Bluecore

Sarfaraz Ahmed
Bluecore Engineering
11 min readFeb 29, 2024

Introduction

React Query (now TanStack Query) is a popular data synchronization library for React based applications. We, at Bluecore, recently adopted the React Query library to manage our server state used by the client application. This blog post talks about how we approached our migration to React Query and provides some code examples to highlight a few important features/benefits of React Query.

Why React Query?

We had heard positive reviews about React Query across various tech communities for some time so there was absolutely no doubt on its usefulness, in general. The only question for us was to figure out if there were strong reasons to start using it here at Bluecore. We started by looking at our code and made some important observations:

  1. Quite a lot of our application components relied heavily on the Redux state selectors. These selectors were simply fetching the loading state of the API and the cached response.
  2. Components relying on the Redux state were dispatching actions that would eventually call the required APIs and set the API loading state.
  3. Some of the API responses and their loading states were being used in multiple components.

With these observations, it was straightforward for us to see how ReactQuery could simplify our data synchronization process and reduce the (server-side) state management. The other nice thing about React Query is that it is perfectly capable of working alongside Redux where data fetching responsibilities are delegated to React Query and management of client state is delegated to Redux.

The decision to use React Query was also driven by another factor. It simplified the process of data fetching through automatically generated React Query clients. This was particularly beneficial during our transition from a monolithic to a microservices-based backend architecture. We adopted Protobuf for microservices communication, creating .proto files that could generate client and server code. These same .proto files could also generate OpenAPI specs, enabling the creation of React Query clients using third-party libraries. This automation provided developers with pre-filled data for data fetching calls, making it easier to integrate React Query into applications.

Traditionally, when using React Query, developers need to manually create clients for data fetching, specifying unique keys for cache identification and types for type safety. Managing these can be time-consuming and error-prone, as they must align with the server counterparts. However, with generated React Query clients, the keys and types are automatically handled. Developers can use the query clients without the need for extensive configuration, streamlining the adoption of React Query in applications.

Planning the migration

The first question in front of us was to decide on a migration approach. We considered both incremental and overhaul approaches and chose the incremental approach primarily because it was less risky and required less dev effort to start with. The choice between the two approaches depends on the specific needs of the project, the size of your codebase, and the team’s familiarity with both Redux and React Query. Incremental migration can be a safer choice for larger, more complex projects (like ours), while a complete overhaul may be more suitable for smaller projects or when you want to fully embrace React Query’s capabilities.

Incremental Approach
Pros:

  1. Reduced Risk: Allows you to minimize risks by not disrupting the entire application at once. You can continue to use Redux in some parts of the application while transitioning to React Query in others, which is especially valuable for larger projects.
  2. Familiarity: Developers who are already comfortable with Redux can continue using it in existing code while learning and adopting React Query incrementally. This approach can ease the learning curve.
  3. Incremental Improvements: You can gradually improve the performance and maintainability of your application by tackling one feature or module at a time. This makes it easier to monitor the impact of each change.

Cons:

  1. Complex Integration: Managing both Redux and React Query concurrently can add complexity to your codebase, potentially leading to increased maintenance overhead.
  2. Potential Confusion: Developers might get confused when dealing with two different state management systems, especially when trying to synchronize data between them.

Overhaul Approach
Pros:

  1. Clean Slate: Starting with React Query from scratch provides a clean slate for your application, eliminating the need to maintain and refactor Redux code gradually.
  2. Full Benefits: You can fully leverage React Query’s features and benefits without any compromises or integration complexities.

Cons:

  1. High Initial Effort: A complete overhaul can be time-consuming and resource-intensive. One will need to rewrite and retest significant portions of your codebase.
  2. Learning Curve: Developers familiar with Redux may need time to learn React Query, potentially slowing down development initially.
  3. Risks: A full-scale migration carries more significant risks, as you’re replacing a core piece of your application’s architecture all at once, which could lead to unforeseen issues.
  4. Maintenance Overhead: Maintenance overhead of two applications as we still need to support the old application while development for the new application with react query is in progress.

Like with any other migration, it was expected that we would run into some unforeseen issues at various stages of the migration. We brainstormed as a team to figure out a way that will help us to roll back React Query related changes without much risk if the need were to arise.

We opted not to make direct modifications to the existing files to be migrated but chose to create a parallel structure and update the new files as per the requirement. The strategy was to use feature toggles to have control over the activation of this new path and when the feature stability had been proven then remove the old code. This decision was bolstered by the fact that the entirety of the code for the targeted route for migration resided neatly within specific files and directories (discovered during earlier code analysis), which simplified the cloning process and facilitated subsequent cleanup. This strategic move spared us from littering our current code with numerous conditional statements. Following this approach, we embarked on a systematic migration, focusing on one API at a time.

React Query In Action

React Query simplifies complex Redux state setups by automating caching, offering built-in state management, and providing tools for automatic invalidation and refetching. This leads to more concise and maintainable code while achieving the same data management goals. Now, let’s see some examples which demonstrate a React Query-based data fetching setup and other code simplifications, in comparison to Redux.

Data Fetching Setup
Let’s assume you have a Redux-based data fetching setup like this:

// Redux reducer
const todoListReducer = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_TODOS_REQUEST':
return { ...state, isLoading: true };
case 'FETCH_TODOS_SUCCESS':
return { ...state, data: action.payload, isLoading: false };
case 'FETCH_TODOS_FAILURE':
return { ...state, isLoading: false };
default:
return state;
}
};

// Redux thunk to perform async data fetching
const fetchTodosAsync = () => {
return (dispatch) => {
dispatch({ type: 'FETCH_TODOS_REQUEST' });
fetch('/todos')
.then((response) => response.json())
.then((data) => {
dispatch({ type: 'FETCH_TODOS_SUCCESS', payload: data });
})
.catch((error) => {
dispatch({ type: 'FETCH_TODOS_FAILURE' });
});
};
};

Now, let’s migrate this to React Query:

import { useQuery } from 'react-query';

// Function to fetch todos (same as before)
const fetchTodos = async () => {
const response = await fetch('/todos');
const data = await response.json();
return data;
};

// React Query usage in your component
const TodoListComponent = () => {
const { data, isLoading, isError } = useQuery('todos', fetchTodos);
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error fetching data</div>;
}

// Render component based on 'data' from React Query
return data.map(
(todo) => <Todo
key={todo.id}
title={todo.title}
description={todo.description}
/>)
};

Here’s what we did:

  1. Imported useQuery from React Query to define data fetching logic directly within the component. To maintain a clean folder structure, one can also define the data fetching logic outside the component code.
  2. Used the same fetchTodos function for data fetching as in the Redux example.
  3. Used the useQuery('todos', fetchTodos) to declare a query named todos that fetches data using the fetchTodos function. React Query handles caching, loading, and error states automatically.
  4. Checked the isLoading and isError states provided by React Query to render loading and error messages as needed.

After successfully migrating the data fetching to React Query and verifying that it was working as expected, the rest of the Redux related code, including actions, reducers, and Redux store configuration that were specific to this data fetching operation could be removed.

Automatic Caching
With React Query, you don’t have to define a separate reducer, and actions, and manually manage caching. React Query automatically caches and manages data, thus reducing boilerplate code.

Redux:

// Redux state
const initialState = {
data: [],
isLoading: false,
};

// Redux reducer
const todoListReducer = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_TODOS_REQUEST':
return { ...state, isLoading: true };
case 'FETCH_TODOS_SUCCESS':
return { ...state, data: action.payload, isLoading: false };
case 'FETCH_TODOS_FAILURE':
return { ...state, isLoading: false };
default:
return state;
}
};

React Query:

import { useQuery } from 'react-query';

const fetchTodos = async () => {
const response = await fetch('/todos');
const jsonResponse = await response.json();
return jsonResponse;
}

// Component using React Query
const TodoListComponent = () => {
const { data, isLoading, isError } = useQuery('todos', fetchTodos);
// No need to manually manage caching - React Query handles it
if (isLoading) {
return 'Fetching';
}

if (isError) {
return 'Error';
}

return data.map(
(todo) => <Todo
key={todo.id}
title={todo.title}
description={todo.description}
/>)
};

Built-in State Management
React Query provides built-in state management through the useQuery hook. Thus, there is no longer a need to define Redux actions and reducers for data fetching states like loading or success; React Query simplifies this process.

Redux:

// Redux actions and reducer (as shown in Step 1)
// Reducer
const todoListReducer = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_TODOS_REQUEST':
return { ...state, isLoading: true };
case 'FETCH_TODOS_SUCCESS':
return { ...state, data: action.payload, isLoading: false };
case 'FETCH_TODOS_FAILURE':
return { ...state, isLoading: false };
default:
return state;
}
};

// Action
const fetchTodos = async (dispatch) => {
try {
dispatch({type: 'FETCH_TODOS_REQUEST'});
const response = await fetch('/todos');
dispatch({type: 'FETCH_TODOS_SUCCESS', payload: response});
} catch (e) {
dispatch({type: 'FETCH_TODOS_FAILURE'})
}
}

import {useEffect} from 'react';
import {useSelector, useDispatch} from 'react-redux';

// Component
const TodoListComponentWithRedux= () => {
const data = useSelector((state) => state.data);
const isLoading = useSelector((state) => state.isLoading);
const dispatch = useDispatch();

// Trigger data fetching when the component mounts
useEffect(() => {
dispatch(fetchTodos());
}, []);

// Render component based on 'data' and 'isLoading' from Redux state
if (isLoading) {
return 'Fetching';
}

return data.map(
(todo) => <Todo
key={todo.id}
title={todo.title}
description={todo.description}
/>)
};

React Query:

import { useQuery } from 'react-query';

const fetchTodos = async () => {
const response = await fetch('/todos');
const jsonResponse = await response.json();
return jsonResponse;
}

// Component using React Query
const TodoListComponentWithReactQuery = () => {
const { data, isLoading } = useQuery('todos', fetchTodos);

// Render component based on 'data' and 'isLoading' from React Query
if (isLoading) {
return 'Fetching';
}

return data.map(
(todo) => <Todo
key={todo.id}
title={todo.title}
description={todo.description}
/>)
};

Automatic Invalidation and Refetching
React Query simplifies cache invalidation and refetching by providing tools like useMutation and queryCache , making it easier to handle data synchronization when data needs to be refreshed.

Redux:

// Redux actions and reducer (as shown in Step 1)
// Reducer
const todoListReducer = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_TODOS_REQUEST':
return { ...state, isLoading: true };
case 'FETCH_TODOS_SUCCESS':
return { ...state, data: action.payload, isLoading: false };
case 'FETCH_TODOS_FAILURE':
return { ...state, isLoading: false };
default:
return state;
}
};

// Action
const fetchTodos = async (dispatch) => {
try {
dispatch({type: 'FETCH_TODOS_REQUEST'});
const response = await fetch('/todos');
dispatch({type: 'FETCH_TODOS_SUCCESS', payload: response});
} catch (e) {
dispatch({type: 'FETCH_TODOS_FAILURE'})
}
}

import {useEffect, Fragment} from 'react';
import {useSelector, useDispatch} from 'react-redux';

// Component
const TodoListComponentWithRedux= () => {
const data = useSelector((state) => state.data);
const isLoading = useSelector((state) => state.isLoading);
const dispatch = useDispatch();

// Trigger data fetching when the component mounts
useEffect(() => {
dispatch(fetchTodos());
}, []);

// Manually trigger a refetch
const handleRefresh = () => {
dispatch(fetchTodos());
};

if (isLoading) {
return 'Fetching';
}

// Render component with a refresh button
return (
<Fragment>
{
data.map(
(todo) => <Todo
key={todo.id}
title={todo.title}
description={todo.description}
/>)
}
<button onClick={handleRefresh}>Refresh</button>
</Fragment>
)
};

React Query:

import {Fragment} from 'react';
import { useQuery, useMutation, queryCache } from 'react-query';

const fetchTodos = async () => {
const response = await fetch('/todos');
const jsonResponse = await response.json();
return jsonResponse;
}

// Component using React Query
const TodoListComponentWithReactQuery = () => {
const { data, isLoading } = useQuery('todos', fetchTodos);
const refreshData = useMutation(() => {
// Manually trigger a refetch
return queryCache.invalidateQueries('todos');
});

if (isLoading) {
return 'Fetching';
}

// Render component with a refresh button
return (
<Fragment>
{
data.map(
(todo) => <Todo
key={todo.id}
title={todo.title}
description={todo.description}
/>)
}
<button onClick={refreshData}>Refresh</button>
</Fragment>
)
};

Unit Testing
React Query can significantly change the unit testing process for applications by simplifying data fetching and state management. Here’s a detailed before-and-after migration example to illustrate these changes.

Redux:
Suppose there is a component that fetches and displays a list of todos using Redux for state management. Testing this component typically involves mocking Redux actions, and reducers, and handling asynchronous behavior:

// Redux reducer
const todosReducer = (state = { data: [], isLoading: false }, action) => {
switch (action.type) {
case 'FETCH_TODOS_REQUEST':
return { ...state, isLoading: true };
case 'FETCH_TODOS_SUCCESS':
return { ...state, data: action.payload, isLoading: false };
case 'FETCH_TODOS_FAILURE':
return { ...state, isLoading: false };
default:
return state;
}
};

// TodoListComponentWithRedux.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import TodoListComponentWithRedux from './TodoListComponentWithRedux';

// Mock the fetch function
jest.mock('fetch', () => ([
{ id: 1, title: 'Item 1', description: 'Item 1 Description' },
{ id: 2, title: 'Item 2', description: 'Item 2 Description' }
]));

// Create a mock Redux store
const mockStore = configureStore([]);
const store = mockStore({
data: [],
isLoading: false,
});

beforeEach(() => {
// Clear mock calls and set initial store state before each test
store.clearActions();
});

// Testing the component
it('renders todos correctly', async () => {
render(
<Provider store={store}>
<TodoListComponentWithRedux />
</Provider>
);

// Assertions based on your component's behavior
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();

// Expect that the fetchData action was dispatched once
expect(store.getActions()).toHaveLength(2);
const expectedPayload = [
{ type: 'FETCH_TODOS_REQUEST' },
{ type: 'FETCH_TODOS_SUCCESS', payload: [
{ id: 1, title: 'Item 1', description: 'Item 1 Description' },
{ id: 2, title: 'Item 2', description: 'Item 2 Description' }
]}
]
expect(store.getActions()).toEqual(expectedPayload)
});

React Query:
Migrating to React Query simplifies the testing process by reducing the need to mock actions, reducers, and Redux boilerplate. Here’s how the testing process changes:

import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import TodoListComponentWithReactQuery from './TodoListComponentWithReactQuery';

const queryClient = new QueryClient();
it('renders todos correctly', async () => {
render(
<QueryClientProvider client={queryClient}>
<TodoListComponentWithReactQuery />
</QueryClientProvider>
);

// Assert that the component renders todos correctly
const todoElement = await screen.findByText('Example Todo');
expect(todoElement).toBeInTheDocument();
});

Key Changes and Benefits:

  1. Simplified Testing Setup: Before React Query, you had to set up complex Redux mocks, actions, and reducers. With React Query, the testing setup becomes much cleaner, focusing on the core functionality of the component.
  2. Focus on Component Behaviour: React Query allows you to concentrate on testing the behavior of the component rather than managing the intricacies of Redux state management.
  3. Realistic Testing: Tests using React Query more closely resemble real-world scenarios because you’re using React Query’s actual data fetching logic. This leads to more realistic and reliable test results.
  4. Efficiency and Readability: React Query tests are often more efficient to write and easier to read. They closely resemble how components interact with data in the application, making it simpler for other developers to understand and maintain the tests.
  5. Testing Loading and Error States: React Query simplifies testing loading and error states as well. You can wait for data to load or simulate error responses easily, making it straightforward to validate how components handle these states.

Overall, React Query enhances the testing and QA process by simplifying data fetching and state management, resulting in cleaner, more focused, and more realistic tests. This, in turn, improves the efficiency and reliability of testing and QA efforts.

Conclusion

React Query provides a modern alternative to do efficient data management in React based applications. It helped us simplify our code, made it efficient, and improved the developer/QA experience. We recommend migrating to React Query, especially for cases where significant data fetching is involved. It is advisable to look at the existing code/implementation in detail before undertaking the migration. Doing so, not only validates some of the assumptions one might have about the code but can also reveal things that you can use to plan your migration better. React Query and Redux work well together and can be used to get the best of both worlds — easier data handling and client state management while also benefiting from the reduction of boilerplate code for data fetching in Redux.

--

--