RTK Query: Simplifying State Management in React

As the landscape of React has evolved over recent years, so have the tools for state management. Redux has long been a dominant player. Its structured approach to managing global states has made it a favorite among developers. However, with the introduction of RTK Query, the game has changed. Let’s dive into how RTK Query is revolutionizing the way we handle states and asynchronous operations in React applications.

1. Redux vs. RTK Query: A Brief Comparison

At its core, Redux is a predictable state container for some Javascript-based frontend apps. It centralizes the application’s state and logic, making managing it easier. On the other hand, RTK Query is a part of the Redux Toolkit and introduces a higher level of abstraction, reducing the need to manually handle and simplify data fetching, caching, synchronization, and state management.

While Redux requires developers to write reducers, actions, and middleware to handle asynchronous calls, RTK Query automates much of this. It provides developers with a set of tools to manage remote data fetching without the boilerplate.

2. The Power of RTK Query

One of the standout features of RTK Query is its ability to automate tasks. It handles caching, invalidation, and re-fetching out of the box, meaning less code for developers and a more efficient way to manage remote data.

Moreover, RTK Query comes with built-in TypeScript support, ensuring type safety and reducing potential runtime errors.

RTK Query uses the concept of  “queries” and “mutations” in order to improve data fetching and caching. It provides inbuilt support for error handling and optimistic updates.

  • Queries — This is the most common use case for RTK Query. You can use query operations with any data-fetching library of your choice.
  • Mutations — A mutation is used to send updates to the server and replicate the same in the local cache.

3. Practical Example: Setting Up RTK Query

Just as we saw a practical setup with Redux here, let’s explore how to integrate RTK Query into a React application:

Installation: Begin by adding the Redux Toolkit package.

npm install @reduxjs/toolkit react-redux

I’ll explain an example based on a success history with my team on our recent project, an admin site.

API Slice File

First of all, we need to define our RTK API file, which acts as our network API interface layer, this file will contain our endpoint definition, and createApi will provide us with an auto-generated hook that manages to fire our requests only when necessary, as well as providing us with request status lifecycle booleans as mentioned previously.

If we are regular Redux users, we need to know that this will completely cover our logic implemented for an entire slice file, including the thunk, slice definitions, selectors, and our custom hook.

In this example, we charge our API Slice with two main endpoints, login, and refreshToken, after the definition of both, we proceed to export the hooks of each endpoint in order to be able to call them in the future alongside the application.

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

export const myApi = createApi({
  reducerPath: "myApi",
  baseQuery: baseQueryWithRetryAndReauth,
  tagTypes: Object.values(ApiTags),
  endpoints: builder => ({
    login: builder.mutation<PostLoginResponse, PostLoginRequest>({
      query: credentials => ({
        url: "auth/login",
        method: "POST",
        body: credentials,
      }),
      extraOptions: { retryCondition: () => false },
    }),
    refreshToken: builder.mutation<PostRefreshTokenResponse, void>({
      query: () => ({
        url: "auth/refresh",
        method: "POST",
      }),
    }),
  }),
})

// Export hooks for usage in function components, which are
// auto-generated based on the defined endpoints
export const { useLoginMutation, useRefreshTokenMutation } = myApi

In the same way, we can also define other slices if we want a better order for our API Slices, just using the injectEndpoints hook, we can extend our API.

import { User } from "@/common/types/user.types"
import {
  GetUsersResponse,
  GetUserWithClassroomsResponse,
  PostRegisterUserRequest,
  PostRegisterUserResponse,
} from "@/global/api/dto/user.dto"
import { myApi } from "@/global/redux/my-api"
import { ApiTags } from "./provider-tags"

export const usersApi = myApi.injectEndpoints({
  endpoints: builder => ({
    getUsers: builder.query<UserDto, void>({
      query: () => `/admin/users`,
      providesTags: [ApiTags.USERS],
    }),
    getUserWithClassrooms: builder.query<
      GetUserWithClassroomsResponse,
      string | string[] | undefined
    >({
      query: userId => `/admin/users/${userId}`,
      providesTags: [ApiTags.USERS],
    }),
})

// Export hooks for usage in function components, which are
// auto-generated based on the defined endpoints
export const {
  useGetUsersQuery,
  useGetUserWithClassroomsQuery,
} = usersApi

IMPORTANT: providesTags is used in each individual mutation endpoint, it can invalidate particular tags for existing cached data. Doing so enables a relationship between cached data from one or more query endpoints and the behavior of one or more mutation endpoints.

Store Setup: Integrate the API slice into your Redux store.

Add the generated reducer as a specific top-level slice, like other normal redux reducers.

import { configureStore } from '@reduxjs/toolkit';
import { api } from './apiSlice';

export const store = configureStore({
  reducer: {
    auth: authReducer,
    ui: uiReducer,
    constants: constantsReducer,
    [myApi.reducerPath]: myApi.reducer,
  },
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware({ serializableCheck: false }).concat(
      myApi.middleware,
    ),
})

Using in Components: Utilize the generated hooks from the API slice in your React components.

  1. useMutation example
import { useLoginMutation } from "@/global/redux/my-api"
…

const EmailLoginForm: FC = () => {
  const dispatch = useAppDispatch()
  const [login] = useLoginMutation()

  return (
    </>
      <Formik
        initialValues={initialValues}
        onSubmit={async (data, formikHelpers: FormikHelpers<any>;) => {
          try {
            const credentials: PostLoginRequest = {
              ...data,
              timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
              app: generalConfig.appForAdmin,
            }
            const response = await login(credentials).unwrap()
            setAccessToken(response.data.access_token)
            formikHelpers.setStatus()
          } catch (error: errorType) {
            //Catch code…
            const apiError = getApiError(error?.data?.data?.errors)
            formikHelpers.setStatus({
              loginFailed: apiError,
            })
            if (apiError) {
              setFormikErrors(apiError, formikHelpers.setFieldError)
            }
          }
        }}
        validationSchema={validationSchema}
        component={EmailLoginFormUI}
      />
    </>
  )
}

2. useQuery + isLoading property example

import { useGetUsersQuery } from "@/global/redux/apis/myapi/users"
…

export default function UsersTable() {
  const { data: result, isLoading } = useGetUsersQuery()
  const goToCreatePage = () => {
    router.push(`${Routes.USERS}/create`)
  }
  return (
    <DataTable
      isLoading={isLoading}
      columns={columns}
      data={result?.data?.users || undefined}
      SearchInputComponent={SearchBarForm}
      searchLabel="Search users"
      searchPlaceholder="Write the user name or email"
      topRightButtonGoTo={goToCreatePage}
      topRightButtonName="New"
    />
  )
}

Extra tip: Below are some of the most frequently used properties on the “mutation result” object. For an extensive list, please take a look here

  • data – The data returned from the latest trigger response, if present. If subsequent triggers from the same hook instance are called, this will return undefined until the new data is received. 
  • error – The error result if present.
  • isUninitialized – When true, indicates that the mutation has not been fired yet.
  • isLoading – When true, indicates that the mutation has been fired and is awaiting a response.
  • isSuccess – When true, indicates that the last mutation fired has data from a successful request.
  • isError – When true, indicates that the last mutation fired resulted in an error state.
  • reset – A method to reset the hook back to it’s original state and remove the current result from the cache

4. Conclusion

Choosing between Redux and RTK Query often boils down to the specific needs of your application. Redux might be the way to go for apps requiring extensive state management. However, for applications that heavily rely on remote data fetching and want to minimize boilerplate, RTK Query is a powerful tool to consider.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.