Complete Guide to Redux-Toolkit & RTK Query with React

Complete Guide to Redux-Toolkit & RTK Query with React

Do you know that you can use Redux Toolkit & its latest addition: RTK Query? Yes, now you can use Redux with features as React Query provides.

RTK Query is a high-level data-fetching and client-side caching tool. Its functionality is similar to React Query but has the advantage of being directly integrated with Redux. For API interaction, developers use async middleware modules when working with Redux. Such an approach limits flexibility. Hence, react developers now have an authority elective from the redux team that covers all the advanced requirements of client/server communication.

This article shows how RTK Query can be utilized in actual situations, and each step incorporates a connection to a commit diff to feature added usefulness.

1. Boilerplate and Configuration

  • Create a project. It can be done using the Create React App (CRA) template with TypeScript and Redux. npx create-react-app . --template redux-typescript

  • It has several dependencies that we will require along the way; some of them are:

    1. Redux Toolkit and RTK Query

    2. React Router

  • It includes the ability to provide custom configuration for webpack. Usually, CRA does not support such capabilities until you eject it.

2. Initialization

A lot more route than eject is to utilize something that can update the configuration, especially if those modifications are minor. This boilerplate uses react-app-rewired and customize-cra to accomplish that functionality to introduce a babel configuration. It can be done as shown below:

const plugins = [
 [
   'babel-plugin-import',
   {
     'libraryName': '@material-ui/core',
     'libraryDirectory': 'esm',
     'camel2DashComponentName': false
   },
   'core'
 ],
 [
   'babel-plugin-import',
   {
     'libraryName': '@material-ui/icons',
     'libraryDirectory': 'esm',
     'camel2DashComponentName': false
   },
   'icons'
 ],
 [
   'babel-plugin-import',
   {
     "libraryName": "lodash",
     "libraryDirectory": "",
     "camel2DashComponentName": false,  // default: true
   }
 ]
];

module.exports = { plugins };
  • The developer can experience better by allowing imports. Such as:
import { omit } from 'lodash';
import { Box } from '@material-ui/core';
  • Imports result in an increased bundle size, but with the rewriting functionality that we configured, these will function like as:
import omit from 'lodash/omit';
import Box from '@material-ui/core/Box';

3. Configuration

Now it's time to set up store configuration. This can be done as shown below:

import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { Reducer } from 'redux';
import {
 FLUSH,
 PAUSE,
 PERSIST,
 persistStore,
 PURGE,
 REGISTER,
 REHYDRATE
} from 'redux-persist';
import { RESET_STATE_ACTION_TYPE } from './actions/resetState';
import { unauthenticatedMiddleware } from './middleware/unauthenticatedMiddleware';

const reducers = {};

const combinedReducer = combineReducers<typeof reducers>(reducers);

export const rootReducer: Reducer<RootState> = (
 state,
 action
) => {
 if (action.type === RESET_STATE_ACTION_TYPE) {
   state = {} as RootState;
 }

 return combinedReducer(state, action);
};

export const store = configureStore({
 reducer: rootReducer,
 middleware: (getDefaultMiddleware) =>
   getDefaultMiddleware({
     serializableCheck: {
       ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
     }
   }).concat([
     unauthenticatedMiddleware
   ]),
});

export const persistor = persistStore(store);

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof combinedReducer>;
export const useTypedDispatch = () => useDispatch<AppDispatch>();
export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
  • Now, we’ll add configuration for a global reset state action that’s useful in real-world apps, both for the apps themselves and for testing.
import { createAction } from '@reduxjs/toolkit';

export const RESET_STATE_ACTION_TYPE = 'resetState';
export const resetStateAction = createAction(
 RESET_STATE_ACTION_TYPE,
 () => {
   return { payload: null };
 }
);
  • We will now add custom middleware for handling 401 responses by:
import { isRejectedWithValue, Middleware } from '@reduxjs/toolkit';
import { resetStateAction } from '../actions/resetState';

export const unauthenticatedMiddleware: Middleware = ({
 dispatch
}) => (next) => (action) => {
 if (isRejectedWithValue(action) && action.payload.status === 401) {
   dispatch(resetStateAction());
 }

 return next(action);
};

**Wow!!! We have created the boilerplate and configured Redux. It's time to add some functionality. **

3. Authentication

At this step, we will add the ability to retrieve the token. These three steps can do authentication:

  1. Adding API definitions to retrieve an access token.

  2. Adding components to handle GitHub web authentication flow.

  3. Finalizing authentication by providing utility components for the user to the whole app.

  • RTK Query features tools for auto-generating API definitions using OpenAPI standards or GraphQL. This library is designed to provide an excellent developer experience with TypeScript. It is progressively becoming a choice for enterprise applications due to its ability to improve maintainability.

  • As we know, definitions will reside under the API folder. Hence we need this:

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

export const AUTH_API_REDUCER_KEY = 'authApi';
export const authApi = createApi({
 reducerPath: AUTH_API_REDUCER_KEY,
 baseQuery: fetchBaseQuery({
   baseUrl: 'https://tp-auth.herokuapp.com',
 }),
 endpoints: (builder) => ({
   getAccessToken: builder.query<AuthResponse, string>({
     query: (code) => {
       return ({
         url: 'github/access_token',
         method: 'POST',
         body: { code }
       });
     },
   }),
 }),
});
  • The open-source authentication server provides GitHub authentication. It can be hosted separately due to the requirements of the GitHub API.

  • The next step is to add components that use this API. We will need a login component responsible for redirecting to GitHub:

import { Box, Container, Grid, Link, Typography } from '@material-ui/core';
import GitHubIcon from '@material-ui/icons/GitHub';
import React from 'react';

const Login = () => {
 return (
   <Container maxWidth={false}>
     <Box height="100vh" textAlign="center" clone>
       <Grid container spacing={3} justify="center" alignItems="center">
         <Grid item xs="auto">
           <Typography variant="h5" component="h1" gutterBottom>
             Log in via Github
           </Typography>
           <Link
             href={`https://github.com/login/oauth/authorize?client_id=b1bd2dfb1d172d1f1589`}
             color="textPrimary"
             data-testid="login-link"
             aria-label="Login Link"
           >
             <GitHubIcon fontSize="large"/>
           </Link>
         </Grid>
       </Grid>
     </Box>
   </Container>
 );
};

export default Login;
  • Now, we need a route to handle the code and retrieve access_token based on it:
import React, { useEffect } from 'react';
import { Redirect } from 'react-router';
import { StringParam, useQueryParam } from 'use-query-params';
import { authApi } from '../../../../api/auth/api';
import FullscreenProgress
 from '../../../../shared/components/FullscreenProgress/FullscreenProgress';
import { useTypedDispatch } from '../../../../shared/redux/store';
import { authSlice } from '../../slice';

const OAuth = () => {
 const dispatch = useTypedDispatch();
 const [code] = useQueryParam('code', StringParam);
 const accessTokenQueryResult = authApi.endpoints.getAccessToken.useQuery(
   code!,
   {
     skip: !code
   }
 );
 const { data } = accessTokenQueryResult;
 const accessToken = data?.access_token;

 useEffect(() => {
   if (!accessToken) return;

   dispatch(authSlice.actions.updateAccessToken(accessToken));
 }, [dispatch, accessToken]);
  • The mechanism for interacting with the API is similar to RTK Query. We do this for the ability to persist the token between page reloads. It provides features for access_token, though we need to save it in the store manually by dispatching an action:
dispatch(authSlice.actions.updateAccessToken(accessToken));
  • We need to define a store configuration for our authentication feature. Per convention, Redux Toolkit refers to slices:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import { AuthState } from './types';

const initialState: AuthState = {};

export const authSlice = createSlice({
 name: 'authSlice',
 initialState,
 reducers: {
   updateAccessToken(state, action: PayloadAction<string | undefined>) {
     state.accessToken = action.payload;
   },
 },
});

export const authReducer = persistReducer({
 key: 'rtk:auth',
 storage,
 whitelist: ['accessToken']
}, authSlice.reducer);
  • Each API has to be provided as a reducer for store configuration, and each API comes with its middleware, which we have to include:
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { Reducer } from 'redux';
import {
 FLUSH,
 PAUSE,
 PERSIST,
 persistStore,
 PURGE,
 REGISTER,
 REHYDRATE
} from 'redux-persist';
import { AUTH_API_REDUCER_KEY, authApi } from '../../api/auth/api';
import { authReducer, authSlice } from '../../features/auth/slice';
import { RESET_STATE_ACTION_TYPE } from './actions/resetState';
import { unauthenticatedMiddleware } from './middleware/unauthenticatedMiddleware';

const reducers = {
 [authSlice.name]: authReducer,
 [AUTH_API_REDUCER_KEY]: authApi.reducer,
};

const combinedReducer = combineReducers<typeof reducers>(reducers);

export const rootReducer: Reducer<RootState> = (
 state,
 action
) => {
 if (action.type === RESET_STATE_ACTION_TYPE) {
   state = {} as RootState;
 }

 return combinedReducer(state, action);
};

export const store = configureStore({
 reducer: rootReducer,
 middleware: (getDefaultMiddleware) =>
   getDefaultMiddleware({
     serializableCheck: {
       ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
     }
   }).concat([
     unauthenticatedMiddleware,
     authApi.middleware
   ]),
});

export const persistor = persistStore(store);

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof combinedReducer>;
export const useTypedDispatch = () => useDispatch<AppDispatch>();
export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;

Great!!! Now our app is retrieving access_token, and we can proceed to complete authentication.

5. RTK Query Repositories

It's time to introduce some additional features to the application for specific scenarios and how we can use used.

  • Feature for repositories: This feature will try to mimic the functionality of the repositories tab that you can experience on GitHub. Its searches for repositories and sort them based on specific criteria.

Let’s add API definitions required to cover the repositories functionality first:

import { endpoint } from '@octokit/endpoint';
import { createApi } from '@reduxjs/toolkit/query/react';
import { githubBaseQuery } from '../index';
import { ResponseWithLink } from '../types';
import { RepositorySearchArgs, RepositorySearchData } from './types';

export const REPOSITORY_API_REDUCER_KEY = 'repositoryApi';
export const repositoryApi = createApi({
 reducerPath: REPOSITORY_API_REDUCER_KEY,
 baseQuery: githubBaseQuery,
 endpoints: (builder) => ({
   searchRepositories: builder.query<
     ResponseWithLink<RepositorySearchData>,
     RepositorySearchArgs
     >(
     {
       query: (args) => {
         return endpoint('GET /search/repositories', args);
       },
     }),
 }),
 refetchOnMountOrArgChange: 60
});
  • Now, let’s introduce a Repository feature consisting of Search/Grid/Pagination:
import { Grid } from '@material-ui/core';
import React from 'react';
import PageContainer
 from '../../../../../../shared/components/PageContainer/PageContainer';
import PageHeader from '../../../../../../shared/components/PageHeader/PageHeader';
import RepositoryGrid from './components/RepositoryGrid/RepositoryGrid';
import RepositoryPagination
 from './components/RepositoryPagination/RepositoryPagination';
import RepositorySearch from './components/RepositorySearch/RepositorySearch';
import RepositorySearchFormContext
 from './components/RepositorySearch/RepositorySearchFormContext';

const Repositories = () => {
 return (
   <RepositorySearchFormContext>
     <PageContainer>
       <PageHeader title="Repositories"/>
       <Grid container spacing={3}>
         <Grid item xs={12}>
           <RepositorySearch/>
         </Grid>
         <Grid item xs={12}>
           <RepositoryGrid/>
         </Grid>
         <Grid item xs={12}>
           <RepositoryPagination/>
         </Grid>
       </Grid>
     </PageContainer>
   </RepositorySearchFormContext>
 );
};

export default Repositories;
  • Now, let’s define custom hooks that will provide us with the ability to:

    1. Get arguments for API calls.

    2. Get the current API result as stored in the state.

    3. Fetch data by calling API endpoints.

import { debounce } from 'lodash';
import { useCallback, useEffect, useMemo } from 'react';
import urltemplate from 'url-template';
import { repositoryApi } from '../../../../../../../api/github/repository/api';
import { RepositorySearchArgs }
 from '../../../../../../../api/github/repository/types';
import { useTypedDispatch } from '../../../../../../../shared/redux/store';
import { useAuthUser } from '../../../../../../auth/hooks/useAuthUser';
import { useRepositorySearchFormContext } from './useRepositorySearchFormContext';

const searchQs = urltemplate.parse('user:{user} {name} {visibility}');
export const useSearchRepositoriesArgs = (): RepositorySearchArgs => {
 const user = useAuthUser()!;
 const { values } = useRepositorySearchFormContext();
 return useMemo<RepositorySearchArgs>(() => {
   return {
     q: decodeURIComponent(
       searchQs.expand({
         user: user.login,
         name: values.name && `${values.name} in:name`,
         visibility: ['is:public', 'is:private'][values.type] ?? '',
       })
     ).trim(),
     sort: values.sort,
     per_page: values.per_page,
     page: values.page,
   };
 }, [values, user.login]);
};

export const useSearchRepositoriesState = () => {
 const searchArgs = useSearchRepositoriesArgs();
 return repositoryApi.endpoints.searchRepositories.useQueryState(searchArgs);
};

export const useSearchRepositories = () => {
 const dispatch = useTypedDispatch();
 const searchArgs = useSearchRepositoriesArgs();
 const repositorySearchFn = useCallback((args: typeof searchArgs) => {
   dispatch(repositoryApi.endpoints.searchRepositories.initiate(args));
 }, [dispatch]);
 const debouncedRepositorySearchFn = useMemo(
   () => debounce((args: typeof searchArgs) => {
     repositorySearchFn(args);
   }, 100),
   [repositorySearchFn]
 );

 useEffect(() => {
   repositorySearchFn(searchArgs);
   // Non debounced invocation should be called only on initial render
   // eslint-disable-next-line react-hooks/exhaustive-deps
 }, []);

 useEffect(() => {
   debouncedRepositorySearchFn(searchArgs);
 }, [searchArgs, debouncedRepositorySearchFn]);

 return useSearchRepositoriesState();
};
  • This level of separation as a layer of abstraction is essential in this case, both from a readability perspective and the RTK Query requirements.

  • You may have noticed that when we introduced a hook that retrieves user data using useQueryState, we had to provide the same arguments we offered for the actual API call.

import { userApi } from '../../../api/github/user/api';
import { User } from '../../../api/github/user/types';

export const useAuthUser = (): User | undefined => {
 const state = userApi.endpoints.getUser.useQueryState(null);
 return state.data?.response;
};
  • That null we provide as an argument is whether we call useQuery or useQueryState. That is required because RTK Query identifies and caches a piece of information by the ideas that were used to retrieve that information in the first place.

One more important thing you need to pay attention to in this piece of code in our API definition:

refetchOnMountOrArgChange: 60
  • We have to do this because one of the important points when using libraries like RTK Query is handling client cache and cache invalidation.

Add more functionality to the repository page by allowing one to view commits for each repository, paginate those commits, and filter by branch. It also tries to mimic the functionality that you’d get on a GitHub page.

While the hover may seem artificial, this heavily impacts UX in real-world applications. It is always handy to have such functionality available in the toolset of the library we use for API interaction.

Wrapping Up!

As per the above data, we can say that RTK Query has various benefits fetching is built on top of Redux, leveraging its state management system and TypeScript to improve the development experience and maintainability. But this library is still in active development so APIs may change Information scarcity besides the documentation, which may be outdated, there isn’t much information around.

I hope you know how to use RTK Query in your apps and handle concerns like state retrieval, cache invalidation, and prefetching.

Did you find this article valuable?

Support Quokka Labs' Blogs by becoming a sponsor. Any amount is appreciated!