Implementing RTK Query in a React Native App
#jest #react-native #redux-toolkit #rtk-query #typescript
10 min read
I had a bit of downtime in between feature builds last week, so I decided to do a time-boxed investigation into how we might improve API querying throughout our React Native app.
We currently use Axios to help with structuring our API calls. Whilst it does what we need it to, there are definitely ways in which we can improve our querying (e.g. through caching), in addition to DRY-ing up our code (e.g. reducing the need to constantly be setting up local states for things like loading and error statuses).
As a starting point, I had heard lots of good things about React Query, but also the newer Redux Toolkit (RTK) Query. Upon doing a quick read, I confirmed both could do the job I needed, but I ultimately decided on RTK Query for a number of reasons:
- We already use Redux Toolkit to help with state management in our app and RTK Query is included in the same package. This meant I didn't have to introduce yet another package to our code base.
- The caching mechanic seemed more straight-forward and easy to understand, to me.
- We can use the Redux DevTools to monitor the query lifecycle.
- Auto-generated React hooks are a nice touch.
If you want to read more about the differences between RTK Query and React Query, check out the docs here.
Decision made, what I wanted to do next was to:
- See how difficult it was to get RTK Query set up and running in our code base;
- Create a proof of concept (PoC) PR to present to my team mates, on how we'd define GET, POST and PATCH endpoints, and how these would be hooked up to the UI; and
- See how easy it is to write Jest tests.
Despite RTK Query being released fairly recently (I think around June 2021?), I found the documentation to be substantial and easy to understand, with the set up process being pretty straightforward. You can get the full instructions from the docs, but I've included some code here for completeness.
Step 1: Set up your API
In my case, I needed to get the user's auth token, to then append to the headers when making an API call. Depending on how auth works for your endpoints, you'll probably need to change this.
// @app/api/rtkApi.ts
import appConfig from '@app/appConfig'
import { AsyncStorageService } from '@app/AsyncStorageService'
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
baseQuery: fetchBaseQuery({
baseUrl: appConfig.apiBase, // e.g. https://yourapi.com
prepareHeaders: async (headers) => {
const user = await AsyncStorageService.getStoredData()
const hasUser = !!user && !!user!.userToken
if (hasUser) {
headers.set('Authorization', `Token ${user.userToken}`)
}
headers.set('Content-Type', 'application/json')
return headers
},
}),
endpoints: () => ({}),
reducerPath: 'api',
tagTypes: ['Game'],
})
Step 2: Define GET, POST and PATCH endpoints
You might have realised that I left the endpoints blank above. This is because I wanted to inject my endpoints at runtime, after the initial API slice has been defined. My main reason for this was extensibility, as our app has lots of endpoints to call. I wanted my PoC PR to show how we would structure the code realistically. This page has more information on this.
// @app/api/gameApi.ts
import { api } from '@app/api/rtkApi'
import { TGameRequest } from '@app/game/GameRequest'
export const gameApi = api.injectEndpoints({
endpoints: (build) => ({
listGames: build.query<TGameRequest[], void>({
providesTags: ['Game'],
query: () => '/games/',
}),
addGame: build.mutation<string, { payload: Partial<TGameRequest>; userId: string }>({
invalidatesTags: ['Game'],
query: ({ userId, payload }) => ({
body: {
user: userId,
...payload,
},
method: 'POST',
url: '/games/',
}),
}),
updateGame: build.mutation<string, { payload: Partial<TGameRequest>; userId: string }>({
invalidatesTags: ['Game'],
query: ({ userId, payload }) => ({
body: {
user: userId,
...payload,
},
method: 'PATCH',
url: `/games/${payload.id}/`,
}),
}),
}),
overrideExisting: false,
})
export const { useListGamesQuery, useAddGameMutation, useUpdateGameMutation } = gameApi
Some things to point out:
listGames
is a GET endpoint. I definedprovidesTags
asGame
. Note that theGame
tag has to be defined intagTypes
within thecreateApi
function.- For the POST and PATCH endpoints, I defined
invalidatesTags
also asGame
. What this means is that whenever I call the POST and PATCH endpoints successfully, thelistGames
data cache will be invalidated and the GET endpoint will be called again automatically to refresh the data. - I don't need to call the
listGames
endpoint with any arguments. This is why you seevoid
as the second Typescript argument forbuild.query
. - Because of how the rest of my code base is set up, my payload excludes the
userId
. It's simpler ifuserId
is sent through as part of thepayload
. ๐ฌ - The
useListGamesQuery
,useAddGameMutation
anduseUpdateGameMutation
hooks are all auto-generated. The names of these hooks are based on the names of the endpoints. GET endpoints end withQuery
, whereas POST, PATCH (and other endpoints that mutate data) end withMutation
. You'll need to be sure you've imported from the package'sreact
folder if you want this feature:import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
Step 3: Complete the remaining RTK Query setup
Add the name of your API to the reducer.
// @app/state/root.ts
import { api } from '@app/api/rtkApi';
...
export default combineReducers({
[api.reducerPath]: api.reducer,
// remaining reducers
});
Add the required middleware and setup listeners to your store.
// @app/state/store.ts
import { api } from '@app/core/api/rtkApi'
import rootReducer from '@app/core/state/root'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { persistReducer, persistStore } from 'redux-persist'
const persistConfig = {
key: 'root',
storage: AsyncStorage,
}
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
devTools: __DEV__,
middleware: getDefaultMiddleware({
serializableCheck: false,
}).concat(api.middleware), // NOTE this addition
reducer: persistedReducer,
})
export const persistor = persistStore(store)
setupListeners(store.dispatch) // NOTE this addition
export default store
Step 4: Use the auto-generated hooks in your UI components
I've simplified my UI screen examples as much as possible, to focus on the core RTK Query features. Here's an example of what my list screen looks like, where I'm calling the listGames
endpoint.
Where I previously would have used useState
hooks to monitor local state for error and loading statuses (in order to display error messages or loading spinners), this is now provided by the useListGamesQuery
hook. i.e. this sort of thing is no longer needed:
const [error, setError] = React.useState<string>()
const [loading, setLoading] = React.useState<boolean>(true)
The other nice thing is that the hook just runs and updates itself automatically - you don't have to wrap it in some kind of useEffect
hook to be called on the screen mounting or updating.
// ListScreen component
import { useListGamesQuery } from '@app/api/gameApi';
// ...other imports
export function ListScreen(props: TProps) {
const { data, isError, isLoading } = useListGamesQuery();
return (
<>
{isLoading ? (
<LoadingSpinner />
) : (
{data.map((item) => {
// ... render your data
})
)
{isError ? <ErrorText>Oops, something went wrong</ErrorText> : null}
</>
)
}
Moving on to the POST and PATCH endpoints - let's say they're both called in the same screen (the AddUpdateScreen
). There's a small difference in how you use mutation hooks, as they don't "run automatically" like the query hooks do. You will instead need to specifically call them at the right point. In the example below, this was on the user clicking a submit button.
// AddUpdateScreen component
import { useAddGamesMutation, useUpdateGamesMutation } from '@app/api/gameApi';
// ...other imports
export function AddUpdateScreen(props: TProps) {
const [addGames, { isLoading: addRequestSubmitting }] = useAddGamesMutation();
const [updateGames, { isLoading: updateRequestSubmitting }] = useUpdateGamesMutation();
const onSubmit = async (values: IGameData) => {
const payload = // sanitise the values received
if (!addRequestSubmitting && !updateRequestSubmitting) {
if (existingGame) {
await updateGames({
userId: props.userId,
payload,
});
} else {
await addGames({
userId: props.userId,
payload,
});
}
}
};
return (
<Button onPress={onSubmit}>
Submit
</Button>
)
}
You'll notice that I renamed the isLoading
destructured prop from the 2 mutation hooks as I wanted to refer to both, in the screen logic. What the onSubmit
function is trying to do is to first create the payload, and then, assuming a submission is not already happening, to either call the POST or PATCH endpoint.
RTK Query hooks comes with a host of other return values like isFetching
and isError
, so check out the docs for queries and mutations to see what's available.
Step 5: Testing
There's not a lot of information in the official docs at the moment, on how to test RTK Query. If you're interested in actually testing that your API endpoints have been set up correctly, and that your auto-generated hooks are working as expected, check out this Medium article which I found super helpful.
The other thing I specifically wanted to test was whether the UI was rendering components correctly, depending on the data that was coming back from the query. This was easily achieved by mocking the response from the hook. I used the jest-fetch-mock library to help with this. Once you've got that installed, be sure to enable it in your Jest setup file.
// In my Jest setup file
require('jest-fetch-mock').enableMocks()
This is an example of what my tests looked like (I'm going to assume you're already testing your Redux store and have something like redux-mock-store
set up). In this case, for each game returned in my GET response, I wanted to check that a GameRow
is rendered.
// In my test file
import * as hooks from '@app/api/gameApi'
import initialState from '@app/store/initialState'
import { GameRow } from '@app/components/GameRow'
import { getDefaultMiddleware } from '@reduxjs/toolkit'
import createMockStore from 'redux-mock-store'
const middlewares = getDefaultMiddleware()
const mockStore = createMockStore(middlewares)
const store = mockStore(initialState) // define your initial state as needed
const RESPONSE_WITH_TWO_GAMES = [
// define what your expected response should look like i.e. of type TGameRequest as defined in your API endpoint
]
describe('ListScreen tests', () => {
it('renders 2 rows when 2 games exist', async () => {
jest
.spyOn(hooks, 'useListGamesQuery')
.mockReturnValue({ data: [RESPONSE_WITH_TWO_GAMES], isError: false, isLoading: false })
const element = (
<ReduxProvider store={store}>
<ListScreen />
</ReduxProvider>
)
const instance = renderer.create(element).root
await act(async () => {
expect(instance.findAllByType(GameRow).length).toBe(2)
})
})
it('renders 0 doses when 0 doses exist', async () => {
jest.spyOn(hooks, 'useListGamesQuery').mockReturnValue({ data: [], isError: false, isLoading: false })
const element = (
<ReduxProvider store={store}>
<ListScreen />
</ReduxProvider>
)
const instance = renderer.create(element).root
await act(async () => {
expect(instance.findAllByType(GameRow).length).toBe(0)
})
})
})
Conclusion
All in all, I really liked working with RTK Query and found it fairly easy to set up. The Redux DevTools were hugely helpful in helping me understand the lifecycle of a query and how caching works, so I'd definitely recommend you install and activate the dev tools if you haven't already.
As we were not using Axios for particularly complex functions, I decided to do away with Axios completely (though you can use both in tandem if you so prefer). There are also a number of other RTK Query features that I haven't yet had the chance to try out, like auto-generating API endpoints from OpenAPI schemas and global error interception and handling.
I was watching React Conf 2021 a couple of days ago, where they featured React Suspense heavily and mentioned that they're currently working with tools with React Query to make it simple for devs to integrate Suspense. I'm guessing that RTK Query must also be on that list and am really intrigued to see how RTK Query evolves with this. ๐