Migrating to RTK Query
- 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 behaviour 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.
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.
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.
// 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.
// 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.
- TypeScript
- JavaScript
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>
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
},
})
In order to have the store accessible within our app, we will wrap our App
component with a Provider
component from react-redux
.
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.
- TypeScript
- JavaScript
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 }
}
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useAppDispatch } from './store'
import {
fetchPokemonByName,
selectStatusByName,
selectDataByName,
} from './services/pokemonSlice'
export function useGetPokemonByNameQuery(name) {
const dispatch = useAppDispatch()
// select the current status from the store state for the provided name
const status = useSelector((state) => selectStatusByName(state, name))
// select the current data from the store state for the provided name
const data = useSelector((state) => 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 behaviour 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 booleansisUninitialized
,isPending
,isFulfilled
&isRejected
in order to determine the current UI at any given moment as a function of our state.
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!
- TypeScript
- JavaScript
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
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
reducerPath: 'pokemonApi',
endpoints: (build) => ({
getPokemonByName: build.query({
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.
- TypeScript
- JavaScript
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>
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),
})
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!
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:
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: