Using RTK Query in React Apps With Redux Toolkit

Using RTK Query in React Apps With Redux Toolkit

Have you ever wanted to use React Query with Redux—or at least, Redux with features like React Query provides? Now you can, by using Redux Toolkit and its latest addition: Redux Toolkit Query, or RTK Query for short.

RTK Query is an advanced data-fetching and client-side caching tool. When it comes to React Query vs. RTK Query, RTK Query’s functionality is similar to React Query but it has the benefit of being directly integrated with Redux. For API interaction, developers typically use async middleware modules like Thunk when working with Redux. Such an approach limits flexibility; thus React developers now have an official alternative from the Redux team that covers all the advanced needs of today’s client/server communication.

This article demonstrates how RTK Query in React apps can be used in real-world scenarios, and each step includes a link to a commit diff to highlight added functionality. A link to the complete codebase appears at the end.

Boilerplate and Configuration

Project Initialization Diff

First, we need to create a project. This is done using the Create React App (CRA) template for use with TypeScript and Redux:

npx create-react-app . --template redux-typescript        

It has several dependencies that we will require along the way, the most notable ones being:

  • Redux Toolkit and RTK Query
  • Material UI
  • Lodash
  • Formik
  • React Router

It also includes the ability to provide custom configuration for webpack . Normally, CRA does not support such abilities unless you eject.

Initialization

A much safer route than eject is to use something that can modify the configuration, especially if those modifications are small. This boilerplate uses react-app-rewired and customize-cra to accomplish that functionality to introduce a custom babel configuration:

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 };        

This makes the developer experience better by allowing imports. For example:

import { omit } from 'lodash';
import { Box } from '@material-ui/core';        

Such imports usually result in an increased bundle size, but with the rewriting functionality that we configured, these will function like so:

import omit from 'lodash/omit';
import Box from '@material-ui/core/Box';        

Configuration

Redux Setup Diff

Since the whole app is based on Redux, after initialization we will need to set up store configuration:

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;        

Apart from the standard store configuration, we’ll add configuration for a global reset state action that comes in handy 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 };
 }
);        

Next, we will add custom middleware for handling 401 responses by simply clearing the store:

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);
};        

So far, so good. We have created the boilerplate and configured Redux. Now let’s add some functionality.

Authentication

Retrieving Access Token Diff

Authentication is broken down into three steps for simplicity:

  • Adding API definitions to retrieve an access token
  • Adding components to handle GitHub web authentication flow
  • Finalizing authentication by providing utility components for providing the user to the whole app

At this step, we add the ability to retrieve the access token.

RTK Query ideology dictates that all the API definitions appear in one place, which is handy when dealing with enterprise-level applications with several endpoints. In an enterprise application, it is much easier to contemplate the integrated API, as well as client caching, when everything is in one place.

RTK Query features tools for auto-generating API definitions using OpenAPI standards or GraphQL . These tools are still in their infancy, but they are being actively developed. In addition, this library is designed to provide excellent developer experience with TypeScript, which is increasingly becoming the choice for enterprise applications due to its ability to improve maintainability.

In our case, definitions will reside under the API folder. For now we have required only 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 }
       });
     },
   }),
 }),
});        

GitHub authentication is provided via an open-source authentication server, which is hosted separately on Heroku due to the requirements of the GitHub API.

The Authentication Server

While not required for this RTK Query example project, readers wishing to host their own copy of the authentication server will need to:

  1. Create an OAuth app in GitHub to generate their own client ID and secret.
  2. Provide GitHub details to the authentication server via the environment variables GITHUB_CLIENT_ID and GITHUB_SECRET.
  3. Replace the authentication endpoint baseUrl value in the above API definitions.
  4. On the React side, replace the client_id parameter in the next code sample.

The next step is to add components that use this API. Due to the requirements of GitHub web application flow , 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;        

Once GitHub redirects back to our app, we will 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]);        

If you’ve ever used React Query, the mechanism for interacting with the API is similar for RTK Query. This provides some neat features thanks to Redux integration that we will observe as we implement additional features. For access_token, though, we still need to save it in the store manually by dispatching an action:

dispatch(authSlice.actions.updateAccessToken(accessToken));        

We do this for the ability to persist the token between page reloads. Both for persistence and the ability to dispatch the action, we need to define a store configuration for our authentication feature.

Per convention, Redux Toolkit refers to these as 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);        

There is one more requirement for the preceding code to function. Each API has to be provided as a reducer for store configuration, and each API comes with its own middleware, which you 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;        

That’s it! Now our app is retrieving access_token and we are ready to add more authentication features on top of it.

Completing Authentication

Completing Authentication Diff

The next feature list for authentication includes:

  • The ability to retrieve the user from the GitHub API and provide it for the rest of the app.
  • The utility to have routes that are only accessible when authenticated or when browsing as a guest.

To add the ability to retrieve the user, we will need some API boilerplate. Unlike with the authentication API, the GitHub API will need the ability to retrieve the access token from our Redux store and apply it to every request as an Authorization header.

In RTK Query that is achieved by creating a custom base query:

import { RequestOptions } from '@octokit/types/dist-types/RequestOptions';
import { BaseQueryFn } from '@reduxjs/toolkit/query/react';
import axios, { AxiosError } from 'axios';
import { omit } from 'lodash';
import { RootState } from '../../shared/redux/store';
import { wrapResponseWithLink } from './utils';

const githubAxiosInstance = axios.create({
 baseURL: 'https://api.github.com',
 headers: {
   accept: `application/vnd.github.v3+json`
 }
});

const axiosBaseQuery = (): BaseQueryFn<RequestOptions> => async (
 requestOpts,
 { getState }
) => {
 try {
   const token = (getState() as RootState).authSlice.accessToken;
   const result = await githubAxiosInstance({
     ...requestOpts,
     headers: {
       ...(omit(requestOpts.headers, ['user-agent'])),
       Authorization: `Bearer ${token}`
     }
   });

   return { data: wrapResponseWithLink(result.data, result.headers.link) };
 } catch (axiosError) {
   const err = axiosError as AxiosError;
   return { error: { status: err.response?.status, data: err.response?.data } };
 }
};

export const githubBaseQuery = axiosBaseQuery();        

I am using axios here, but other clients can be used too.

The next step is to define an API for retrieving user information from GitHub:

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

export const USER_API_REDUCER_KEY = 'userApi';
export const userApi = createApi({
 reducerPath: USER_API_REDUCER_KEY,
 baseQuery: githubBaseQuery,
 endpoints: (builder) => ({
   getUser: builder.query<ResponseWithLink<User>, null>({
     query: () => {
       return endpoint('GET /user');
     },
   }),
 }),
});        

We use our custom base query here, meaning that every request in the scope of userApi will include an Authorization header. Let’s tweak the main store configuration so that the API is available:

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 { USER_API_REDUCER_KEY, userApi } from '../../api/github/user/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,
 [USER_API_REDUCER_KEY]: userApi.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,
     userApi.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;        

Next, we need to call this API before our app is rendered. For simplicity, let’s do it in a manner that resembles how the resolve functionality works for Angular routes so that nothing is rendered until we get user information.

The absence of the user can also be handled in a more granular way, by providing some UI beforehand so the user has a first meaningful render more quickly. This requires more thought and work, and should definitely be addressed in a production-ready app.

To do that, we need to define a middleware component:

import React, { FC } from 'react';
import { userApi } from '../../../../api/github/user/api';
import FullscreenProgress
 from '../../../../shared/components/FullscreenProgress/FullscreenProgress';
import { RootState, useTypedSelector } from '../../../../shared/redux/store';
import { useAuthUser } from '../../hooks/useAuthUser';

const UserMiddleware: FC = ({
 children
}) => {
 const accessToken = useTypedSelector(
   (state: RootState) => state.authSlice.accessToken
 );
 const user = useAuthUser();

 userApi.endpoints.getUser.useQuery(null, {
   skip: !accessToken
 });

 if (!user && accessToken) {
   return (
     <FullscreenProgress/>
   );
 }

 return children as React.ReactElement;
};

export default UserMiddleware;        

What this does is straightforward. It interacts with the GitHub API to get user information and doesn’t render children before the response is available. Now if we wrap the app functionality with this component, we know that user information will be resolved before anything else renders:

import { CssBaseline } from '@material-ui/core';
import React from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter as Router, Route, } from 'react-router-dom';
import { PersistGate } from 'redux-persist/integration/react';
import { QueryParamProvider } from 'use-query-params';
import Auth from './features/auth/Auth';
import UserMiddleware
 from './features/auth/components/UserMiddleware/UserMiddleware';
import './index.css';
import FullscreenProgress
 from './shared/components/FullscreenProgress/FullscreenProgress';
import { persistor, store } from './shared/redux/store';

const App = () => {
 return (
   <Provider store={store}>
     <PersistGate loading={<FullscreenProgress/>} persistor={persistor}>
       <Router>
         <QueryParamProvider ReactRouterRoute={Route}>
           <CssBaseline/>
           <UserMiddleware>
             <Auth/>
           </UserMiddleware>
         </QueryParamProvider>
       </Router>
     </PersistGate>
   </Provider>
 );
};

export default App;        

Let’s move on to the sleekest part. We now have the ability to get user information anywhere in the app, even though we didn’t save that user information in-store manually like we did with access_token.

How? By creating a simple custom React Hook for it:

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;
};        

RTK Query provides the useQueryState option for every endpoint, which gives us the ability to retrieve the current state for that endpoint.

Why is this so important and useful? Because we don’t have to write a lot of overhead to manage code. As a bonus, we get a separation between API/client data in Redux out of the box.

Using RTK Query avoids the hassle. By combining data fetching with state management, RTK Query eliminates the gap that would otherwise be there even if we were to use React Query. (With React Query, fetched data has to be accessed by unrelated components on the UI layer.)

As a final step, we define a standard custom route component that uses this hook to determine if a route should be rendered or not:

import React, { FC } from 'react';
import { Redirect, Route, RouteProps } from 'react-router';
import { useAuthUser } from '../../hooks/useAuthUser';

export type AuthenticatedRouteProps = {
 onlyPublic?: boolean;
} & RouteProps;

const AuthenticatedRoute: FC<AuthenticatedRouteProps> = ({
 children,
 onlyPublic = false,
 ...routeProps
}) => {
 const user = useAuthUser();

 return (
   <Route
     {...routeProps}
     render={({ location }) => {
       if (onlyPublic) {
         return !user ? (
           children
         ) : (
           <Redirect
             to={{
               pathname: '/',
               state: { from: location }
             }}
           />
         );
       }

       return user ? (
         children
       ) : (
         <Redirect
           to={{
             pathname: '/login',
             state: { from: location }
           }}
         />
       );
     }}
   />
 );
};

export default AuthenticatedRoute;        

Authentication Tests Diff

There is nothing inherently specific when it comes to writing tests for RTK Query in React apps. Personally, I am in favor of Kent C. Dodds’ approach to testing and a testing style that focuses on user experience and user interaction. Nothing much changes when using RTK Query.

That being said, each step will still include its own tests to demonstrate that an app written with RTK Query is perfectly testable.

Note: The example shows my take on how those tests should be written in regard to what to test, what to mock, and how much code reusability to introduce.

RTK Query Repositories

To showcase RTK Query, we will introduce some additional features to the application to see how it performs in certain scenarios and how it can be used.

Repositories Diff and Tests Diff

The first thing we will do is introduce a feature for repositories. This feature will try to mimic the functionality of the Repositories tab that you can experience in GitHub. It will visit your profile and have the ability to search for repositories and sort them based on certain criteria. There are many file changes introduced in this step. I encourage you to dig into the parts that you are interested in.

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
});        

Once that is ready, 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;        

Interaction with the Repositories API is more complex than what we have encountered so far, so let’s define custom hooks that will provide us with the ability to:

  • Get arguments for API calls.
  • Get the current API result as stored in the state.
  • 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();
};        

Having this level of separation as a layer of abstraction is important in this case both from a readability perspective and due to the RTK Query requirements.

As you may have noticed when we introduced a hook that retrieves user data by using useQueryState, we had to provide the same arguments we provided 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 there whether we call useQuery or useQueryState. That is required because RTK Query identifies and caches a piece of information by the arguments that were used to retrieve that information in the first place.

This means we need to be able to get arguments required for an API call separately from the actual API call at any point in time. That way, we can use it to retrieve the cached state of API data whenever we need to.

There is one more thing you need to pay attention to in this piece of code in our API definition:

refetchOnMountOrArgChange: 60        

Why? Because one of the important points when using libraries like RTK Query is handling client cache and cache invalidation. This is vital and also requires a substantial amount of effort, which may be difficult to provide depending on the phase of development you are in.

I found RTK Query to be very flexible in that regard. Using this configuration property allows us to:

  • Disable caching altogether, which comes in handy when you want to migrate toward RTK Query, avoiding cache issues as an initial step.
  • Introduce time-based caching, a simple invalidation mechanism to use when you know that some information can be cached for X amount of time.

Commits

Commits Diff and Tests Diff

This step adds more functionality to the repository page by adding an ability 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.

We have introduced two more endpoints for getting branches and commits, as well as custom hooks for these endpoints, following the style we established during the implementation of repositories:

github/repository/api.ts

import { endpoint } from '@octokit/endpoint';
import { createApi } from '@reduxjs/toolkit/query/react';
import { githubBaseQuery } from '../index';
import { ResponseWithLink } from '../types';
import {
 RepositoryBranchesArgs,
 RepositoryBranchesData,
 RepositoryCommitsArgs,
 RepositoryCommitsData,
 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);
       },
     }),
   getRepositoryBranches: builder.query<
     ResponseWithLink<RepositoryBranchesData>,
     RepositoryBranchesArgs
     >(
     {
       query(args) {
         return endpoint('GET /repos/{owner}/{repo}/branches', args);
       }
     }),
   getRepositoryCommits: builder.query<
     ResponseWithLink<RepositoryCommitsData>, RepositoryCommitsArgs
     >(
     {
       query(args) {
         return endpoint('GET /repos/{owner}/{repo}/commits', args);
       },
     }),
 }),
 refetchOnMountOrArgChange: 60
});        

useGetRepositoryBranches.ts

import { useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { repositoryApi } from '../../../../../../../api/github/repository/api';
import { RepositoryBranchesArgs }
 from '../../../../../../../api/github/repository/types';
import { useTypedDispatch } from '../../../../../../../shared/redux/store';
import { useAuthUser } from '../../../../../../auth/hooks/useAuthUser';
import { CommitsRouteParams } from '../types';

export const useGetRepositoryBranchesArgs = (): RepositoryBranchesArgs => {
 const user = useAuthUser()!;
 const { repositoryName } = useParams<CommitsRouteParams>();
 return useMemo<RepositoryBranchesArgs>(() => {
   return {
     owner: user.login,
     repo: repositoryName,
   };
 }, [repositoryName, user.login]);
};

export const useGetRepositoryBranchesState = () => {
 const queryArgs = useGetRepositoryBranchesArgs();
 return repositoryApi.endpoints.getRepositoryBranches.useQueryState(queryArgs);
};

export const useGetRepositoryBranches = () => {
 const dispatch = useTypedDispatch();
 const queryArgs = useGetRepositoryBranchesArgs();

 useEffect(() => {
   dispatch(repositoryApi.endpoints.getRepositoryBranches.initiate(queryArgs));
 }, [dispatch, queryArgs]);

 return useGetRepositoryBranchesState();
};        

useGetRepositoryCommits.ts

import isSameDay from 'date-fns/isSameDay';
import { useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { repositoryApi } from '../../../../../../../api/github/repository/api';
import { RepositoryCommitsArgs }
 from '../../../../../../../api/github/repository/types';
import { useTypedDispatch } from '../../../../../../../shared/redux/store';
import { useAuthUser } from '../../../../../../auth/hooks/useAuthUser';
import { AggregatedCommitsData, CommitsRouteParams } from '../types';
import { useCommitsSearchFormContext } from './useCommitsSearchFormContext';

export const useGetRepositoryCommitsArgs = (): RepositoryCommitsArgs => {
 const user = useAuthUser()!;
 const { repositoryName } = useParams<CommitsRouteParams>();
 const { values } = useCommitsSearchFormContext();
 return useMemo<RepositoryCommitsArgs>(() => {
   return {
     owner: user.login,
     repo: repositoryName,
     sha: values.branch,
     page: values.page,
     per_page: 15
   };
 }, [repositoryName, user.login, values]);
};

export const useGetRepositoryCommitsState = () => {
 const queryArgs = useGetRepositoryCommitsArgs();
 return repositoryApi.endpoints.getRepositoryCommits.useQueryState(queryArgs);
};

export const useGetRepositoryCommits = () => {
 const dispatch = useTypedDispatch();
 const queryArgs = useGetRepositoryCommitsArgs();

 useEffect(() => {
   if (!queryArgs.sha) return;

   dispatch(repositoryApi.endpoints.getRepositoryCommits.initiate(queryArgs));
 }, [dispatch, queryArgs]);

 return useGetRepositoryCommitsState();
};

export const useAggregatedRepositoryCommitsData = (): AggregatedCommitsData => {
 const { data: repositoryCommits } = useGetRepositoryCommitsState();
 return useMemo(() => {
   if (!repositoryCommits) return [];

   return repositoryCommits.response.reduce((aggregated, commit) => {
     const existingCommitsGroup = aggregated.find(a => isSameDay(
       new Date(a.date),
       new Date(commit.commit.author!.date!)
     ));
     if (existingCommitsGroup) {
       existingCommitsGroup.commits.push(commit);
     } else {
       aggregated.push({
         date: commit.commit.author!.date!,
         commits: [commit]
       });
     }

     return aggregated;
   }, [] as AggregatedCommitsData);
 }, [repositoryCommits]);
};        

Having done this, we can now improve the UX by prefetching commits data as soon as someone hovers over the repository name:

import {
 Badge,
 Box,
 Chip,
 Divider,
 Grid,
 Link,
 Typography
} from '@material-ui/core';
import StarOutlineIcon from '@material-ui/icons/StarOutline';
import formatDistance from 'date-fns/formatDistance';
import React, { FC } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { repositoryApi } from '../../../../../../../../api/github/repository/api';
import { Repository } from '../../../../../../../../api/github/repository/types';
import { useGetRepositoryBranchesArgs }
 from '../../../Commits/hooks/useGetRepositoryBranches';
import { useGetRepositoryCommitsArgs }
 from '../../../Commits/hooks/useGetRepositoryCommits';

const RepositoryGridItem: FC<{ repo: Repository }> = ({
 repo
}) => {
 const getRepositoryCommitsArgs = useGetRepositoryCommitsArgs();
 const prefetchGetRepositoryCommits = repositoryApi.usePrefetch(
   'getRepositoryCommits');
 const getRepositoryBranchesArgs = useGetRepositoryBranchesArgs();
 const prefetchGetRepositoryBranches = repositoryApi.usePrefetch(
   'getRepositoryBranches');

 return (
   <Grid container spacing={1}>
     <Grid item xs={12}>
       <Typography variant="subtitle1" gutterBottom aria-label="repository-name">
         <Link
           aria-label="commit-link"
           component={RouterLink}
           to={`/repositories/${repo.name}`}
           onMouseEnter={() => {
             prefetchGetRepositoryBranches({
               ...getRepositoryBranchesArgs,
               repo: repo.name,
             });
             prefetchGetRepositoryCommits({
               ...getRepositoryCommitsArgs,
               sha: repo.default_branch,
               repo: repo.name,
               page: 1
             });
           }}
         >
           {repo.name}
         </Link>
         <Box marginLeft={1} clone>
           <Chip label={repo.private ? 'Private' : 'Public'} size="small"/>
         </Box>
       </Typography>
       <Typography component="p" variant="subtitle2" gutterBottom
                   color="textSecondary">
         {repo.description}
       </Typography>
     </Grid>
     <Grid item xs={12}>
       <Grid container alignItems="center" spacing={2}>
         <Box clone flex="0 0 auto" display="flex" alignItems="center"
              marginRight={2}>
           <Grid item>
             <Box clone marginRight={1} marginLeft={0.5}>
               <Badge color="primary" variant="dot"/>
             </Box>
             <Typography variant="body2" color="textSecondary">
               {repo.language}
             </Typography>
           </Grid>
         </Box>
         <Box clone flex="0 0 auto" display="flex" alignItems="center"
              marginRight={2}>
           <Grid item>
             <Box clone marginRight={0.5}>
               <StarOutlineIcon fontSize="small"/>
             </Box>
             <Typography variant="body2" color="textSecondary">
               {repo.stargazers_count}
             </Typography>
           </Grid>
         </Box>
         <Grid item>
           <Typography variant="body2" color="textSecondary">
             Updated {formatDistance(new Date(repo.pushed_at), new Date())} ago
           </Typography>
         </Grid>
       </Grid>
     </Grid>
     <Grid item xs={12}>
       <Divider/>
     </Grid>
   </Grid>
 );
};

export default RepositoryGridItem;
        

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

The Pros and Cons of RTK Query

Final Source Code

We have seen how to use RTK Query in our apps, how to test those apps, and how to handle different concerns like state retrieval, cache invalidation, and prefetching.

There are a number of high-level benefits showcased throughout this article:

  • Data fetching is built on top of Redux, leveraging its state management system.
  • API definitions and cache invalidation strategies are located in one place.
  • TypeScript improves the development experience and maintainability.

There are some downsides worth noting as well:

  • The library is still in active development and, as a result, APIs may change.
  • Information scarcity: Besides the documentation, which may be out of date, there isn’t much information around.

We covered a lot in this practical RTK/React walkthrough using the GitHub API, but there is much more to RTK Query, such as:

If you’re intrigued by RTK Query’s benefits, I encourage you to dig into those concepts further. Feel free to use this RTK Query example as a basis to build on.

要查看或添加评论,请登录

III Amigoes的更多文章

社区洞察

其他会员也浏览了