Why we had to move away from React Query

July 25, 2022

Robert Cooper
Full Stack Engineer

Last year we started using React Query for all of our API calls (and we talked about it in this article about optimizing API calls). However, the more we were using it, the more obvious it became that React Query was not providing the ideal structure for our data. We ended up moving away from React Query, and are much happier with our new setup.

The product

First, let me quickly describe the product we're building to explain the constraints we're working with.

At Basedash, we're building a tool that lets you generate an admin panel to view and edit data, just by connecting your database. It eliminates the need to build custom admin apps for your non-technical teammates. Instead of building a custom app (complete with authentication, hosting, database access, validation, real-time multiplayer, edit history, etc.) you just connect your database, choose which tables should be accessible, and invite your teammates.

Given the nature of our product, we had two important requirements for our state management system:

  • Optimistic updates
  • Normalized store

These are especially important features for us because we're working with both real-time multiplayer and complex long-running queries.

React Query has some support for optimistic updates, but it turned out to be difficult to implement in a reliable way due to its lack of a normalized store. Let's get into why.

How things work with React Query

When you make an API call with React Query using the useQuery hook, the resulting data will be stored in a React Query cache. You can read from the cache using getQueryData.

The benefit of this is that React Query handles all the complexity of caching and reloading data as needed, so you don’t have to do all of that manually.

The main problem with React Query appears when you need to update data in your application. React Query provides a useMutation hook that you can use to make API calls that will update data on your server, and then you can update the data in the React Query cache in the onSuccess or onMutate callback functions.

The easiest way to update the data in the React Query cache is to call invalidateQueries, where you can specify all the queries that should be refetched as a result of data having changed on the server. However, there are a few problems with having to invalidate queries like this:

  • You need to make sure you’re always invalidating any query related to the data you’re updating. This is very error prone since it’s easy to forget which queries should be invalidated.
  • You need to make sure that the query keys you are using in your invalidateQueries call line up with the query keys specified in the useQuery hook. This caused a lot of problems for us since some of our query keys are large objects and it was easy to forget a property in the query key object. TypeScript can help mitigate this to a certain extent, but many of the bugs we encountered were caused by this problem.
  • Some API calls take a long time to complete, and so the UI would not update instantly if relying on invalidateQueries.

Using invalidateQueries is not the only way to update data in the React Query cache. You can also perform optimistic updates by surgically updating the data in the React Query cache using setQueryData. Updating the data in the cache directly has the advantage of making the UI update instantly, but came with the following downsides:

  • You need to know every place where the data could exist in the React Query store. The same piece of data could be fetched from different useQuery calls, so you would need to update each instance of that data in the store. It’s easy to forget to update data in a certain spot in the React Query cache.
  • The data structure in which the data is structured in the React Query cache is not optimized to allow for updating pieces of data.

Relying on Redux instead

After about a year of struggling with React Query, we decided to use a new (old) approach.

We opted to the use native fetch API to make our API calls, and then stored the results in a global store using Redux. With Redux with Redux Toolkit, we’re easily able to structure data in our store in such a way that there is a single source of truth thanks to a normalized state structure.

As an example, we we can create a slice for apps using createEntityAdapter which uses the following TypeScript types:

type AppEntity = {
	id: number;
	createdAt: Date;
	updatedAt: Date;
	name: string;
	workspaceId: number;

	memberIds: number[];
	viewIds: number[];
}

// Redux state for app looks as follows:
type ReduxState = {
	...
	apps: {
		ids: number[];
		entities: {
			[id: number]: AppEntity;
		}
	};
	...
}

Notice how AppEntity has memberIds and viewIds. These ID arrays are used to reference the members and views entities in our normalized store, rather than nesting objects, ensuring that there is only a single source of truth for our entities. As a nice side effect, this more closely matches how data is being stored in our SQL database.

Because the entities in our global store are never duplicated, we are able to update data in our store in a single place, and the UI elements that reference that piece of data will all update properly.

It’s also easy to update entities in our Redux store since all entities are accessible via an entities object that is a mapping of entities by ID. Redux Toolkit also provides a lot of helper functions that can be used to easily update entities. Contrast that with React Query where some of the entities could be within deeply nested arrays making it quite inefficient to try to pinpoint and update those entities.

Why Redux?

Redux wasn’t the only option we could have chosen to use for our global store. In fact, we made an effort at trying out valtio for a while (see the pros/cons comparison we made in the image below). However, we ultimately chose Redux since it provides better built-in tooling (via Redux Toolkit) for structuring our store and updating data.

Pros and cons list we made when deciding what global store solution we would like to use.

We are much happier with the state of our data fetching and caching in our web app now. We are running into fewer bugs with the usage of Redux, and the team is able to quickly develop in Redux with the help of Redux Toolkit.

If you’re interested in seeing the results of our newly Redux-ified app, you can try building your own admin panel in Basedash or join our demo workspace. We’re building a tool that lets generate an admin panel for your team just by connecting your database. You can build custom views and queries of your data and share them with your teammates to give them limited read/write access to certain tables.

Check out our docs for more info.

What is Basedash?

Ship your product faster.
Worry about internal tools less.

No credit card required.

More posts like this