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.
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:
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.
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:
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:
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:
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.
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.
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.
Get to know what Basedash can do and how it changes traditional internal tools.
See a full app that connects to a Postgres database and external API made from scratch.