RTK Query: A better way to redux

26 August 2022

26 Aug 2022

26/8/22

Jason Crawley

9 MIN READ

9 MIN READ

Something I have been doing for awhile now as an app developer is use the Redux framework for React Native apps. Not only for state management, but for asynchronous processing with the aid of additional libraries. Redux is great, but it can require a lot of boilerplate code for what are essentially similar tasks. Recently I discovered Redux Toolkit (RTK), a toolset aimed at increasing productivity with Redux development. In particular, it contains a data fetching and caching framework called RTK Query. This framework has significantly reduced the amount of boilerplate I have to write to code asynchronous operations.

The old way

Before looking at RTK Query, let’s first see how we can do asynchronous processing in Redux with redux-observable, typesafe-actions and selectors. We’ll use the free API, https://agify.io/, which provides the age (a random number) of a given name string. Keep an eye out for the amount of boilerplate required.

What is Redux?

Redux is a framework which provides an architecture for state management. A global centralised store is used for shared state. This store can only be changed by dispatching serialised actions, which allows multiple components to use this store in a thread-safe manner.

Defining the structure

In Redux we need to define the structure for the name partition. This partition will be responsible for storing the state of calls to https://agify.io/. Below we define the interface’s Response and NameState. Response is the data structure we expect from the API. NameState is the data structure we will store in Redux. This has the properties request, response and fetching so that we can keep track of the state of our request. The constant INITIAL_NAME_STATE will be used to set the initial state of the Redux partition.

export interface Response {
  name: string;
  age: number;
  count: number;
}

export interface NameState {
  request?: string;
  response?: Response;
  fetching: boolean;
}

export const INITIAL_NAME_STATE: NameState = {
  request: undefined,
  response: undefined,
  fetching: false,
};


Defining the actionsTo change the state of Redux and trigger our asynchronous processing we first need to define the actions that will be dispatched. Redux out of the box doesn’t provide strongly-typed actions, so we’ll use the typesafe-actions library. The getName action is defined below using the createAsyncAction helper function defining request, response and error actions.

import * as Types from '../NameApi/types';
import {ActionType, createAsyncAction} from 'typesafe-actions';

export const NameActions = {
  getName: createAsyncAction(
    'GET_NAME_REQUEST',
    'GET_NAME_SUCCESS',
    'GET_NAME_ERROR',
  )<string, Types.Response, Error>(),
};

export type NameAction = ActionType<typeof NameActions>;

Defining the reducer

To mutate the Redux store, we define reducers to manage partitions of the store and how they should change when actions are received. Reducers receive all actions, but return a new state when their partition changes. The reducer below processes the getName.request, getName.success and getName.error actions. These actions result in a new state being created, updating the global Redux store. All other actions result in the same state being returned.

import {ActionType, getType} from 'typesafe-actions';
import * as Types from './types';
import {NameActions} from './actions';
import {AnyAction} from 'redux';

export const reducer = (
  state: Types.NameState = Types.INITIAL_NAME_STATE,
  action: ActionType<AnyAction>,
) => {
  switch (action.type) {
    case getType(NameActions.getName.request): {
      return {
        ...state,
        fetching: true,
        request: action.payload,
        error: undefined,
        response: undefined,
      };
    }

    case getType(NameActions.getName.success): {
      return {
        ...state,
        fetching: false,
        response: action.payload,
      };
    }

    case getType(NameActions.getName.failure): {
      return {
        ...state,
        fetching: false,
        error: action.payload,
      };
    }
  }
};

Define the epic

The redux-observable library provides a framework for defining epics, which allows us to perform asynchronous operations in response to Redux actions. Epics use the RxJS framework and respond to dispatched Redux actions or changes to Redux State. getNameEpic listens for getName.request actions, and attempts a HTTP fetch of the endpoint. On success, an action will be dispatched containing the response. If the request fails, an action will be dispatched containing the error. These actions will be processed by the reducer defined above.

import {isActionOf} from 'typesafe-actions';
import {NameActions} from './actions';
import {Action} from 'redux';
import {ActionsObservable} from 'redux-observable';
import {catchError, filter, mergeMap} from 'rxjs/operators';
import {from, of} from 'rxjs';

export const getNameEpic = (action$: ActionsObservable<Action>) =>
  action$.pipe(
    filter(isActionOf(NameActions.getName.request)),
    mergeMap(action => {
      const url = 'https://api.agify.io/';

      return from(fetch(url + '?name=' + action.payload)).pipe(
        mergeMap(response => {
          if (response.status >= 200 && response.status < 300) {
            try {
              return response.json();
            } catch (error) {
              throw {message: 'Error' + JSON.stringify(error)};
            }
          } else {
            throw {error: 'Invalid response'};
          }
        }),
        mergeMap(response => {
          return of(NameActions.getName.success(response));
        }),
        catchError(error => {
          return of(NameActions.getName.failure(error));
        }),
      );
    }),
  );

Defining the selector

A selector is a function which takes the entire Redux store and returns the data we are interested in. The reselect library adds memoization and the ability to chain selectors. This is useful when dealing with complicated stores or when we want to apply calculations to raw state. For this example we just want to get the name partition from the Redux store, so we use a simple function.

import {RootState} from '../store';

export const nameStateSelector = (state: RootState) => state.name;

Using the endpoint

In a React Native functional component, we must first dispatch the request action with the name parameter. We then use the useSelector hook to get the nameState from the Redux store. We can use the properties in nameState with the component. This variable will get updated when the Redux partition changes.

const dispatch = useDispatch();
dispatch(NameActions.getName.request('Jason'));
const nameState = useSelector(nameStateSelector);

Summary

As you can see this is a lot of code to perform a simple remote fetch of data. If we have multiple components which need to make different requests for the same endpoint, and extra complexity is required to ensure the Redux store is in the right state. As we shall see, RTK Query significantly reduces the amount of code required for the same task, and also offers more utility.

What is RTK Query?

RTK Query is a framework for data fetching and caching within the Redux ecosystem. It requires us to define APIs, focusing on data and where to get it from. Once defined, RTK Query generates actions, reducers, thunks and hooks for interacting with the APIs. In most cases it is sufficient for React Native functional components to simply use the generated hooks.

Defining an API

RTK Query just needs an API to be defined. This includes shared configuration between endpoints and definitions for the endpoints themselves. Below is an API definition to use the https://agify.io/ API.

import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react';

export interface Response {
  name: string;
  age: number;
  count: number;
}

export const nameApi = createApi({
  reducerPath: 'name',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://api.agify.io/',
  }),

  endpoints: build => ({
    getAge: build.query<Response, String>({
      query: args => {
        return {
          url: '?name=' + args,
        };
      },
    }),
  }),
});

ThereducerPath propertydefines the Redux partition name where this API will store its data.

A base query function is specified via the baseQuery property. This defines the function to perform common processing when requesting data. Here we are using fetchBaseQuery which will perform fetches using the base URL https://agify.io/.

Endpoints define the shape of data and how to get it. The code above defines a single endpoint, getAge, to perform the name query. This is a query endpoint and requires a string argument. When used, a fetch query https://agify.io/?name=<args> will be triggered, where args is the parameter provided. If successful this will return the Response object containing the name, age and number of times this endpoint has been called.

Using the API

The easiest way to use the API is by using the generated React Hooks within functional components. One of the hooks generated is known as the use hook. Use hooks are generated in the naming convention use<endpoint_name><endpoint_type>. The getAge endpoint will generate the useGetAgeQuery hook. The code below shows how we can use this hook inside a functional component.

const getAgeQuery = nameApi.useGetAgeQuery('Jason');

The useGetAgeQuery hook triggers a fetch from https://agify.io/. These occur when the component first mounts or the parameters to the hook change, and cause actions to be dispatched to the Redux store. Below is a screenshot of the React Native Debugger displaying the action history and the Redux state.

Notice how there are name/executeQuery/pending and name/executeQuery/fulfilled actions being dispatched? nameExecuteQuery/pending is dispatched when the request is made. At this point in time the Redux partition for the query will be in a fetching state. When name/executeQuery/fulfilled is dispatched, we have a response from the API and the endpoint’s partition in Redux will contain the response data.

The endpoint’s partition in Redux is shared by all the endpoints’ generated hooks. This data will remain in Redux whilst the hooks are referenced in a mounted component. When all the components referencing the hooks are removed the data will eventually be deleted. By default this is one minute but can be configured when defining the API.

If multiple components are using the hook with different arguments, all the results are stored in Redux. Notice how the data structure is storing the result under the key getAge(“Jason”). Jason is the parameter provided to the query. If another component uses the useGetAgeQuery hook with the parameter “Noel”, a new getAge("Noel") instance is added to the Redux store.

The data structure returned from the useGetAgeQuery hook is shown below. This has a data property containing the response returned by the endpoint. This will be undefined until we get a successful response. Other properties provide information on the state of the fetch. The isLoading property indicates if data is being fetched and can be bound to spinners, for example.

{
  "status":"fulfilled",
  "endpointName":"getAge",
  "requestId":"-pVhrt5J_f5dCiIC0JByQ",
  "originalArgs":"Jason",
  "startedTimeStamp":1654145839831,
  "data":{
    "name":"Jason",
    "age":53,
    "count":62634
  },
  "fulfilledTimeStamp":1654145841571,
  "isUninitialized":false,
  "isLoading":false,
  "isSuccess":true,
  "isError":false,
  "currentData":
    {
     "name":"Jason",
     "age":53,
     "count":62634
    },
  "isFetching":false
}

Summary

With RTK Query we just have to define the API and use the generated hooks. Actions, Thunks and Reducers are automatically created for us. Thunks are an alternative to epics for performing asynchronous actions. If we need a new endpoint using the same base URL, we can simply add it to the builder creating the endpoints.

Auto-fetching with Tags

One of the cool features of RTK Query is the ability to define tags. Tags can be provided by query endpoints and invalidated by mutation endpoints. When a tag has been invalidated, query endpoints providing that tag will automatically trigger. Query endpoints are intended for those endpoints that get data without changing the state, whereas mutation endpoints are intended for those that change the state.

Vehicle Example

Below I have defined a simple API to maintain a list of vehicles. This has a query endpoint getCars which returns the list of vehicles, and addCar which adds a vehicle to the list. For simplicity I’m just using a global list which is an array of strings.

Tags used by the API must be defined in tagTypes. Here we define one tag called Vehicles. This is provided by the getCars query, via the providesTags property, and invalidated by the addCar mutation, via the invalidatesTags property.

The endpoints use queryFn to override the base query function. getCars simply returns the global list, whereas addCar creates a new list with the added car.

import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react';

let list: string[] = ['Car 1'];

export const vehicleApi = createApi({
  reducerPath: 'vehicles',
  baseQuery: fetchBaseQuery({
    baseUrl: '',
  }),
  tagTypes: ['Vehicles'],

  endpoints: build => ({
    getCars: build.query<string[], void>({
      queryFn: () => ({
        data: list,
      }),

      providesTags: ['Vehicles'],
    }),

    addCar: build.mutation<string, string>({
      queryFn: car => {
        list = [...list, car];

        return {data: 'added'};
      },

      invalidatesTags: ['Vehicles'],
    }),
  }),
});

We can use the useGetCarsQuery hook to get the list of cars and the useAddCarMutation hook to update the list. Below is a very basic functional component using these hooks.

import * as React from 'react';
import {View, Text, TextInput, TouchableOpacity} from 'react-native';
import {vehicleApi} from '../redux/VehicleApi/vehicleApi';

export default function CarScreen() {
  const [newCar, setNewCar] = React.useState('');

  const getCarsApi = vehicleApi.useGetCarsQuery();

  const [addCar] = vehicleApi.useAddCarMutation();

  return (
    <View style={{height: '100%'}}>
      <Text>Name Screen</Text>

      <View style={{flexDirection: 'row', marginTop: 50, alignItems: 'center'}}>
        <Text>Name</Text>
        <View style={{width: 10}} />
        <TextInput
          style={{
            borderColor: 'black',
            borderWidth: 2,
            flex: 1,
            height: 50,
          }}
          value={newCar}
          onChangeText={v => setNewCar(v)}
        />
        <TouchableOpacity
          style={{
            backgroundColor: 'green',
            width: 50,
            alignItems: 'center',
            margin: 5,
            padding: 5,
          }}
          onPress={() => {
            addCar(newCar);
            setNewCar('');
          }}>
          <Text>Add</Text>
        </TouchableOpacity>
      </View>

      <View>
        <Text> Cars</Text>
        <View style={{height: 10}} />
        {getCarsApi.data?.map(car => {
          return <Text>{car}</Text>;
        })}
      </View>
    </View>
  );
}

A list of cars is rendered from the data returned by the useGetCarsQuery hook. The useAddCarMutation hook returns a callback, addCar. This will be used to trigger updates to the list of vehicles. When a user types a name and presses the Add button, the addCar endpoint will be used.

Once this endpoint completes, the Vehicle tag will be invalidated, causing the getCars endpoint to be triggered, and the up-to-date list to be displayed. In the React Native Debugger screenshot below we can see that Car 2 was added, followed by Car 3. Notice after each vehicles/executeMutation/fulfilled action a vehicles/executeQuery/pending is dispatched. This is because each mutation invalidates the Vehicle tag causing the getCars query to be triggered again.

As a mobile app developer RTK Query has revolutionised the way I use Redux. It significantly reduces the amount of boilerplate I write. It also reduces the amount of mobile app testing I do. Defining an API allows me to focus on the shape of the data and the endpoints being hit, instead of the mechanics of performing asynchronous tasks. Here I’ve only shown the basics of RTK Query, and I encourage you to check out some of its advanced features.