Skip to main content

Migrating to RTK Query

What You'll Learn
  • How to convert conventional data-fetching logic implemented with Redux Toolkit + createAsyncThunk to use Redux Toolkit Query

Overview

The most common use case for side effects in Redux apps is fetching data. Redux apps typically use a tool like thunks, sagas, or observables to make an AJAX request, and dispatch actions based on the results of the request. Reducers then listen for those actions to manage loading state and cache the fetched data.

RTK Query is purpose-built to solve the use case of data fetching. While it can't replace all of the situations where you'd use thunks or other side effects approaches, using RTK Query should eliminate the need for most of that hand-written side effects logic.

RTK Query is expected to cover a lot of overlapping behavior that users may have previously used createAsyncThunk for, including caching purposes, and request lifecycle management (e.g. isUninitialized, isLoading, isError states).

In order to migrate data-fetching features from existing Redux tools to RTK Query, the appropriate endpoints should be added to an RTK Query API slice, and the previous feature code deleted. This generally will not include much common code kept between the two, as the tools work differently and one will replace the other.

If you're looking to get started with RTK Query from scratch, you may also wish to see RTK Query Quick Start.

Example - Migrating data-fetching logic from Redux Toolkit to RTK Query

A common method used to implement simple, cached, data-fetching logic with Redux is to set up a slice using createSlice, with state containing the associated data and status for a query, using createAsyncThunk to handle the asynchronous request lifecycles. Below we will explore an example of such an implementation, and how we can later go about migrating that code to use RTK Query instead.

note

RTK Query also provides many more features than what is created with the thunk example shown below. The example is only intended to demonstrate how the particular implementation could be replaced with RTK Query.

Design specifications

For our example, the design specifications required for the tool are as follows:

  • Provide a hook to fetch data for a pokemon using the api: https://pokeapi.co/api/v2/pokemon/bulbasaur, where bulbasaur can be any pokemon name
  • A request for any given name should only be sent if it hasn't already done so for the session
  • The hook should provide us with the current status of the request for the supplied pokemon name; whether it is in an 'uninitialized', 'pending', 'fulfilled', or 'rejected' state
  • The hook should provide us with the current data for the supplied pokemon name

With the above specifications in mind, lets first look at an overview of how this could be implemented traditionally using createAsyncThunk combined with createSlice.

Implementation using createSlice & createAsyncThunk

Slice file

The three snippets below make up our slice file. This file is concerned with managing our asynchronous request lifecycles, as well as storing our data & request statuses for a given pokemon name.

Thunk action creator

Below we create a thunk action creator using createAsyncThunk in order to manage asynchronous request lifecycles. This will be accessible within components & hooks to be dispatched, in order to fire off a request for some pokemon data. createAsyncThunk itself will handle dispatching lifecycle methods for our request: pending, fulfilled, and rejected, which we will handle within our slice.

src/services/pokemonSlice.ts - Thunk Action Creator
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import type { Pokemon } from './types'
import type { RootState } from '../store'

export const fetchPokemonByName = createAsyncThunk<Pokemon, string>(
'pokemon/fetchByName',
async (name, { rejectWithValue }) => {
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`)
const data = await response.json()
if (response.status < 200 || response.status >= 300) {
return rejectWithValue(data)
}
return data
},
)

// slice & selectors omitted

Slice

Below we have our slice created with createSlice. We have our reducers containing our request handling logic defined here, storing the appropriate 'status' and 'data' in our state based on the name we search with.

src/services/pokemonSlice.ts - slice logic
// imports & thunk action creator omitted

type RequestState = 'pending' | 'fulfilled' | 'rejected'

export const pokemonSlice = createSlice({
name: 'pokemon',
initialState: {
dataByName: {} as Record<string, Pokemon | undefined>,
statusByName: {} as Record<string, RequestState | undefined>,
},
reducers: {},
extraReducers: (builder) => {
// When our request is pending:
// - store the 'pending' state as the status for the corresponding pokemon name
builder.addCase(fetchPokemonByName.pending, (state, action) => {
state.statusByName[action.meta.arg] = 'pending'
})
// When our request is fulfilled:
// - store the 'fulfilled' state as the status for the corresponding pokemon name
// - and store the received payload as the data for the corresponding pokemon name
builder.addCase(fetchPokemonByName.fulfilled, (state, action) => {
state.statusByName[action.meta.arg] = 'fulfilled'
state.dataByName[action.meta.arg] = action.payload
})
// When our request is rejected:
// - store the 'rejected' state as the status for the corresponding pokemon name
builder.addCase(fetchPokemonByName.rejected, (state, action) => {
state.statusByName[action.meta.arg] = 'rejected'
})
},
})

// selectors omitted

Selectors

Below we have our selectors defined, allowing us to later access the appropriate status & data for any given pokemon name.

src/services/pokemonSlice.ts - selectors
// imports, thunk action creator & slice omitted

export const selectStatusByName = (state: RootState, name: string) =>
state.pokemon.statusByName[name]
export const selectDataByName = (state: RootState, name: string) =>
state.pokemon.dataByName[name]

Store

In our store for our app, we include the corresponding reducer from our slice under the pokemon branch in our state tree. This lets our store handle the appropriate actions for our requests we will dispatch when running the app, using the logic defined previously.

src/services/store.ts
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'

export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
},
})

export type RootState = ReturnType<typeof store.getState>

In order to have the store accessible within our app, we will wrap our App component with a Provider component from react-redux.

src/index.ts
import { render } from 'react-dom'
import { Provider } from 'react-redux'

import App from './App'
import { store } from './store'

const rootElement = document.getElementById('root')
render(
<Provider store={store}>
<App />
</Provider>,
rootElement,
)

Custom hook

Below we create a hook to manage sending our request at the appropriate time, as well as obtaining the appropriate data & status from the store. useDispatch and useSelector are used from react-redux in order to communicate with the Redux store. At the end of our hook, we return the information in a neat, packaged object to be accessed in components.

src/hooks.ts
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useAppDispatch } from './store'
import type { RootState } from './store'
import {
fetchPokemonByName,
selectStatusByName,
selectDataByName,
} from './services/pokemonSlice'

export function useGetPokemonByNameQuery(name: string) {
const dispatch = useAppDispatch()
// select the current status from the store state for the provided name
const status = useSelector((state: RootState) =>
selectStatusByName(state, name)
)
// select the current data from the store state for the provided name
const data = useSelector((state: RootState) => selectDataByName(state, name))
useEffect(() => {
// upon mount or name change, if status is uninitialized, send a request
// for the pokemon name
if (status === undefined) {
dispatch(fetchPokemonByName(name))
}
}, [status, name, dispatch])

// derive status booleans for ease of use
const isUninitialized = status === undefined
const isLoading = status === 'pending' || status === undefined
const isError = status === 'rejected'
const isSuccess = status === 'fulfilled'

// return the import data for the caller of the hook to use
return { data, isUninitialized, isLoading, isError, isSuccess }
}

Using the custom hook

Our code above meets all of the design specifications, so let's use it! Below we can see how the hook can be called in a component, and return the relevant data & status booleans.

Our implementation below provides the following behavior in the component:

  • When our component is mounted, if a request for the provided pokemon name has not already been sent for the session, send the request off
  • The hook always provides the latest received data when available, as well as the request status booleans isUninitialized, isPending, isFulfilled & isRejected in order to determine the current UI at any given moment as a function of our state.
src/App.tsx
import * as React from 'react'
import { useGetPokemonByNameQuery } from './hooks'

export default function App() {
const { data, isError, isLoading } = useGetPokemonByNameQuery('bulbasaur')

return (
<div className="App">
{isError ? (
<>Oh no, there was an error</>
) : isLoading ? (
<>Loading...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} />
</>
) : null}
</div>
)
}

A runnable example of the above code can be seen below:

Converting to RTK Query

Our implementation above does work perfectly fine for the requirements specified, however, extending the code to include further endpoints could involve a lot of repetition. It also has some certain limitations that may not be immediately obvious. For example, multiple components rendering simultaneously calling our hook would each send off a request for bulbasaur at the same time!

Below we will walk through how a lot of the boilerplate can be avoided by migrating the above code to use RTK Query instead. RTK Query will also handle many other situations for us, including de-duping requests on a more granular level to prevent sending unnecessary duplicate requests like that brought up above.

API Slice File

Our code below is for our API slice definition. This acts as our network API interface layer, and is created using createApi. This file will contain our endpoint definition, and createApi will provide us with an auto-generated hook which manages firing our request only when necessary, as well as providing us with request status lifecycle booleans.

This will completely cover our logic implemented above for the entire slice file, including the thunk, slice definition, selectors, and our custom hook!

src/services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'

export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
reducerPath: 'pokemonApi',
endpoints: (build) => ({
getPokemonByName: build.query<Pokemon, string>({
query: (name) => `pokemon/${name}`,
}),
}),
})

export const { useGetPokemonByNameQuery } = api

Connecting the API slice to the store

Now that we have our API definition created, we need to hook it up to our store. In order to do that, we will need to use the reducerPath and middleware properties from our created api. This will allow the store to process the internal actions that the generated hook uses, allows the generated API logic to find the state correctly, and adds the logic for managing caching, invalidation, subscriptions, polling, and more.

src/store.ts
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
import { api } from './services/api'

export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (gDM) => gDM().concat(api.middleware),
})

export type RootState = ReturnType<typeof store.getState>

Using our auto-generated hook

At this basic level, the usage of the auto-generated hook is identical to our custom hook! All we need to do is change our import path and we're good to go!

src/App.tsx
  import * as React from 'react'
- import { useGetPokemonByNameQuery } from './hooks'
+ import { useGetPokemonByNameQuery } from './services/api'

export default function App() {
const { data, isError, isLoading } = useGetPokemonByNameQuery('bulbasaur')


return (
<div className="App">
{isError ? (
<>Oh no, there was an error</>
) : isLoading ? (
<>Loading...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} />
</>
) : null}
</div>
)
}

Cleaning up unused code

As mentioned previously, our api definition has replaced all of the logic that we implemented previously using createAsyncThunk, createSlice, and our custom hook definition.

Given that we're no longer using that slice any longer, we can remove the import and reducer from our store:

src/store.ts
  import { configureStore } from '@reduxjs/toolkit'
- import { pokemonSlice } from './services/pokemonSlice'
import { api } from './services/api'


export const store = configureStore({
reducer: {
- pokemon: pokemonSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (gDM) => gDM().concat(api.middleware),
})

export type RootState = ReturnType<typeof store.getState>

We can also remove the entire slice and hook files completely!

- src/services/pokemonSlice.ts (-51 lines)
- src/hooks.ts (-34 lines)

We've now re-implemented the full set of design specifications (and more!) in less than 20 lines of code, with room to easily expand by adding additional endpoints onto our api definition.

A runnable example of our re-factored implementation using RTK Query can be seen below: