Avatar

Hej, I'm Julia.

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 defined providesTags as Game. Note that the Game tag has to be defined in tagTypes within the createApi function.
  • For the POST and PATCH endpoints, I defined invalidatesTags also as Game. What this means is that whenever I call the POST and PATCH endpoints successfully, the listGames 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 see void as the second Typescript argument for build.query.
  • Because of how the rest of my code base is set up, my payload excludes the userId. It's simpler if userId is sent through as part of the payload. ๐Ÿ˜ฌ
  • The useListGamesQuery, useAddGameMutation and useUpdateGameMutation hooks are all auto-generated. The names of these hooks are based on the names of the endpoints. GET endpoints end with Query, whereas POST, PATCH (and other endpoints that mutate data) end with Mutation. You'll need to be sure you've imported from the package's react 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. ๐Ÿ˜„

ยฉ 2016-2024 Julia Tan ยท Powered by Next JS.