Tanstack react query or rtk query

Background

I've been using tanstack query (react-query) for a while and have gotten used to the patterns around it. Before that, I've used useSWR, and apollo/urql. While I'd prefer to use graphql when available, react-query has been fantastic for working with REST APIs.

A project I joined has redux but not yet rtk or rtk query so I thought I'd give both a try. RTK has been great for simplifying redux, but if we don't need as much data in the global store anymore, I wonder why we'd want to use rtk query.

React Query

The feature comparison sums up most of the differences so I'll just talk about some of the things I noticed when first picking up rtk query and why I prefer react-query.

RTK query

Example

From the docs there's a createApi wrapper that takes a base url and endpoints. The endpoints are defined using a builder.


// Need to use the React-specific entry point to import createApi
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: (builder) => ({
getPokemonByName: builder.query<any, { name: string }>({
query: ({ name }) => `pokemon/${name}`,
}),
}),
})
// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi

  • reducerPath is needed to know where to put the slice of data.
  • baseQuery is the fetch function
  • builder is a function that provides a route with the query key

The query being the query key is the most confusing part coming from react-query. I admit that understanding this part of react-query takes some time as well but the idea of query key and query path have been merged here. The nice thing is it's true that most of the time this is the same. With a more complex API the body is also passed from here. If we want to transform the data, a transformResponse function can be provided or a queryFn.

It turns out queryFn is closer to what react-query does, however, then we need to specify the serialization of query args serializeQueryArgs. Not overly complex but this stuff is all done by react-query without having to understand what is happening. react-query's queryKey is stably serialized by default and not something I need to think about.

Wiring up the store

Each new slice of state also needs to be added as middleware to the router. While not horrible, it's just one more thing to remember to do. Also there will then be some naming convention to follow to make sure the slice is named the same as the reducerPath. I'm lazy and I like minimal api surface area so less is more. I totally understand that registering it is kind of necessary, but react-query doesn't need to declare things because it's in full control of it's global cache.


import { configureStore } from '@reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'
export const store = configureStore({
reducer: {
// Add the generated reducer as a specific top-level slice
[pokemonApi.reducerPath]: pokemonApi.reducer,
},
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(pokemonApi.middleware),
})
// optional, but required for refetchOnFocus/refetchOnReconnect behaviors
// see `setupListeners` docs - takes an optional callback as the 2nd arg for customization
setupListeners(store.dispatch)

Cool stuff

Export hooks are auto generated based on the aformentioned createApi. This is really neat and reminiscent of trpc (which is also awesome). This is where we see the power of the factory function and there's probably some cool string template types going on here. Still, with react-query things are just exported as the function that is created.


// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi

Hooks in components

The base api is the same which is great, but it's here that I notice the difference in control when making a wrapper query.


import * as React from 'react'
import { useGetPokemonByNameQuery } from './services/pokemon'
export default function App() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
// Individual hooks are also accessible under the generated endpoints:
// const { data, error, isLoading } = pokemonApi.endpoints.getPokemonByName.useQuery('bulbasaur')
// render UI based on data and loading state
}

The main options that we have to adjust are:

  • pollingInterval
  • refetchOnFocus
  • refetchOnMountOrArgChange
  • refetchOnReconnect
  • selectFromResult

compare this to a react-query hook wrapper where we can add options inline


import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
import { Environment } from '@/selectors/environment'
const queryKeyId = 'environments'
export function useGetEnvironmentsQuery({
namespace,
appName,
}: {
namespace?: string
appName?: string
}) {
return useQuery({
queryKey: [queryKeyId, { namespace, appName }],
queryFn: async () => {
const res = await axios.get<Environment[]>(
`/api/apps/${namespace}/${appName}/environments`
)
return res?.data
},
enabled: Boolean(namespace && appName),
})
}
useGetEnvironmentsQuery.queryKeyId = queryKeyId

We have basically the same but several that are useful, including immediately having the lazy version in refetch, specifying the enabled with the query, specifying the initialData lower down, events on each of the success/error states, specifying the staletime.

Mutations

These are hard in almost any case. rtk-query has a neat concept of defining a fixed-cache-key


export const ComponentOne = () => {
// Triggering `updatePostOne` will affect the result in both this component,
// but as well as the result in `ComponentTwo`, and vice-versa
const [updatePost, result] = useUpdatePostMutation({
fixedCacheKey: 'shared-update-post',
})
return <div>...</div>
}
export const ComponentTwo = () => {
const [updatePost, result] = useUpdatePostMutation({
fixedCacheKey: 'shared-update-post',
})
return <div>...</div>
}

Why use rtk query

It would only be if something in the store needs to access something else in the store. From my experience, most of the code my team and I write has absolutely minimal shared global state. Most of the "slices" of state can be contained in the common hook that is used to fetch the data or with a selector that is used to get the data from the store. Most of these cases are handled by react-query in a way that isn't also tightly coupled to redux.

It seems like rtk query is mostly trying to follow the features in react-query so newer features will almost always typically land in react-query first.

Given that both operate in the same area, and that global shared mutable state should be avoided, I'm going to reach for react-query whenever I need to deal with rest based async data.