This piece was revised on August 5, 2022, to incorporate the latest data and sources and to enhance clarity, as per our editorial team’s review.
Note: Refactored API slicing approach & Updated RTK Query version - commit.
Ever wished to harness the power of React Query within your Redux applications, or at least leverage Redux with React Query-like features? Look no further than Redux Toolkit and its latest offering: Redux Toolkit Query, or RTK Query in short.
RTK Query is a sophisticated tool designed for data fetching and client-side caching. When compared to React Query, RTK Query offers similar functionality but with the added advantage of seamless integration with Redux. Traditionally, developers relied on async middleware like Thunk for API interaction in Redux. However, this method posed limitations to flexibility. In response, the Redux team has introduced an official alternative specifically for React developers, catering to the complex demands of modern client/server communication.
This article provides a practical guide to implementing RTK Query in real-world React applications. Each step includes a link to a corresponding commit diff, highlighting the added functionality. A link to the complete codebase is provided at the end.
Starting Point: Boilerplate and Configuration
Project Initialization Diff
Our journey begins with project creation using the Create React App (CRA) template, configured for TypeScript and Redux:
1
| npx create-react-app . --template redux-typescript
|
This setup includes various dependencies we’ll utilize, most notably:
- Redux Toolkit and RTK Query
- Material UI
- Lodash
- Formik
- React Router
Furthermore, it allows for custom configuration of webpack, a feature not natively supported by CRA without ejection.
Setting the Stage: Initialization
Instead of ejecting, a safer and more efficient approach is to employ tools that enable configuration modification, especially for minor tweaks. Our boilerplate leverages react-app-rewired and customize-cra to achieve this, introducing a custom Babel configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| 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 approach enhances the developer experience by enabling cleaner imports. For instance:
1
2
| import { omit } from 'lodash';
import { Box } from '@material-ui/core';
|
While such imports often lead to larger bundle sizes, our rewriting configuration ensures they function as:
1
2
| import omit from 'lodash/omit';
import Box from '@material-ui/core/Box';
|
Fine-Tuning: Configuration
Redux Setup Diff
Given the Redux foundation of our application, store configuration is the next crucial step:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| 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;
|
Beyond the standard store setup, we’ll incorporate configuration for a global reset state action. This proves invaluable in real-world scenarios, both for the application itself and for testing purposes:
1
2
3
4
5
6
7
8
9
| import { createAction } from '@reduxjs/toolkit';
export const RESET_STATE_ACTION_TYPE = 'resetState';
export const resetStateAction = createAction(
RESET_STATE_ACTION_TYPE,
() => {
return { payload: null };
}
);
|
Additionally, we’ll integrate custom middleware to handle 401 responses by clearing the store:
1
2
3
4
5
6
7
8
9
10
11
12
| 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);
};
|
With the boilerplate established and Redux configured, we’re ready to infuse functionality.
Secure Access: Authentication
Retrieving Access Token Diff
For clarity, we’ll break down authentication into three manageable steps:
- Defining API interactions for retrieving an access token.
- Implementing components to handle GitHub’s web authentication flow.
- Finalizing authentication with utility components, granting user access throughout the app.
Our first task is enabling access token retrieval.
RTK Query promotes a centralized location for all API definitions, a boon for large-scale applications with multiple endpoints. This centralized approach simplifies the management of the integrated API and client-side caching in complex projects.
RTK Query offers tools for auto-generating API definitions using OpenAPI standards or GraphQL. While still under development, these tools show promise and are actively being improved. Furthermore, the library prioritizes a smooth developer experience with TypeScript, a language gaining traction in enterprise applications for its maintainability advantages.
In our project, API definitions will reside within the API folder. For now, we only require the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| 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 }
});
},
}),
}),
});
|
We’ll utilize an open-source authentication server for GitHub authentication, hosted separately on Heroku to accommodate GitHub API requirements.
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:
- Create an OAuth app in GitHub to generate their own client ID and secret.
- Provide GitHub details to the authentication server via the environment variables
GITHUB_CLIENT_ID and GITHUB_SECRET. - Replace the authentication endpoint
baseUrl value in the above API definitions. - On the React side, replace the
client_id parameter in the next code sample.
Next, we introduce components leveraging this API. To comply with GitHub web application flow requirements, we need a login component responsible for redirecting to GitHub:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| 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;
|
Upon redirection from GitHub, our app needs a route to process the received code and fetch the access_token:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| 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]);
|
Those familiar with React Query will find the API interaction mechanism in RTK Query quite similar. However, the Redux integration offers unique advantages that we’ll explore as we implement further features. For now, we need to manually save the access_token to the store by dispatching an action:
1
| dispatch(authSlice.actions.updateAccessToken(accessToken));
|
This ensures token persistence across page reloads. To enable both persistence and action dispatching, we need to define store configuration for our authentication feature.
In Redux Toolkit terminology, these are known as slices:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| 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);
|
For the code to function correctly, each API must be provided as a reducer for store configuration, and each API comes bundled with its own middleware that needs inclusion:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| 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;
|
With that, our app can now retrieve the access_token, paving the way for additional authentication features.
Authentication: The Final Touches
Completing Authentication Diff
The next phase of authentication implementation involves:
- Retrieving user data from the GitHub API and making it accessible application-wide.
- Implementing route utilities to differentiate between authenticated users and guests.
To retrieve user data, we need some API boilerplate. Unlike our authentication API, the GitHub API requires retrieving the access token from our Redux store and applying it as an Authorization header to every request.
RTK Query addresses this through custom base queries:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| 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();
|
While this example uses axios, other clients are compatible as well.
Now, let’s define the API for fetching user information from GitHub:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| 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');
},
}),
}),
});
|
By using our custom base query, every request within the userApi scope automatically includes the Authorization header. Let’s adjust the main store configuration to incorporate this API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| 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;
|
Before rendering our application, we need to call this API. For simplicity, we’ll mimic the behavior of Angular’s resolve functionality, preventing rendering until user information is retrieved.
A more granular approach would involve displaying a placeholder UI while fetching user data, providing a faster initial render. This optimization, although requiring more thought and effort, is recommended for production-ready applications.
To implement this, we define a middleware component:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| 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;
|
This component interacts with the GitHub API to fetch user data, rendering its children only after a response is received. Wrapping our app’s functionality with this component ensures user information is available before any rendering occurs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| 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;
|
Now for the most impressive part: we can access user information anywhere in the app without manually storing it in the store, unlike the access_token.
This is made possible through a simple custom React Hook:
1
2
3
4
5
6
7
| 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 each endpoint, allowing us to retrieve its current state.
This is crucial because it eliminates the need for excessive code to manage state. As a bonus, we get a clear separation between API and client data in Redux out of the box.
RTK Query streamlines this process by combining data fetching with state management, bridging the gap that would exist if we were using React Query. Without RTK Query, unrelated components on the UI layer would need to access fetched data.
Finally, we define a standard custom route component that utilizes this hook to determine route rendering based on authentication status:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| 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
Testing RTK Query in React applications doesn’t require any special techniques. Personally, I subscribe to Kent C. Dodds’ (https://kentcdodds.com) approach to testing, advocating for a style that emphasizes user experience and interaction. This remains consistent when working with RTK Query.
Each step in this guide includes tests to demonstrate the testability of an RTK Query-powered application.
Note: The examples provided reflect my personal approach to testing, encompassing aspects like what to test, mocking strategies, and code reusability.
RTK Query: Exploring Repositories
To showcase RTK Query’s capabilities, we’ll introduce additional features to our application, exploring its performance and usage in specific scenarios.
Repositories Diff and Tests Diff
First, we’ll implement a repository feature mimicking the functionality of GitHub’s Repositories tab. This feature will allow users to browse their profile, search for repositories, and sort them based on specific criteria. Due to the extensive code changes involved in this step, I encourage you to focus on the areas that pique your interest.
Let’s begin by defining the API interactions necessary for repository functionality:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| 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
});
|
With the API defined, we introduce a Repository feature encompassing Search, Grid, and Pagination components:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| 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;
|
Interacting with the Repositories API is more involved than our previous examples. Therefore, we’ll define custom hooks to streamline the following:
- Retrieving arguments for API calls.
- Accessing the current API result stored in the state.
- Fetching data by invoking API endpoints.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| 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 abstraction through separate layers is crucial for readability and to adhere to RTK Query’s requirements.
Recall the useQueryState hook we introduced to retrieve user data. We had to provide the same arguments used in the original API call.
1
2
3
4
5
6
7
| 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;
};
|
The null argument is required whether we use useQuery or useQueryState. This is because RTK Query identifies and caches information based on the arguments used for its initial retrieval.
Therefore, we need a mechanism to access API call arguments independently of the actual call. This enables us to retrieve cached API data whenever necessary.
There’s one more noteworthy detail in our API definition:
1
| refetchOnMountOrArgChange: 60
|
Why is this important? Because effective client cache and invalidation management are critical aspects of utilizing libraries like RTK Query. This often involves significant effort, which might be challenging depending on the development stage of your project.
RTK Query offers great flexibility in this regard. By employing this configuration property, we can:
- Completely disable caching. This proves useful during initial migration to RTK Query, allowing us to sidestep cache-related issues.
- Implement time-based caching, a straightforward invalidation mechanism for data with a known cache duration.
Commits: A Closer Look
Commits Diff and Tests Diff
In this step, we enhance the repository page by adding the ability to view commits for each repository, complete with pagination and branch filtering. The goal is to replicate the experience of browsing commits on GitHub.
We introduce two new endpoints for fetching branches and commits, along with custom hooks for these endpoints, adhering to the style established during repository implementation:
github/repository/api.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
| 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]);
};
|
With this functionality in place, we can enhance UX by prefetching commit data when a user hovers over a repository name:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
| 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 interaction might seem trivial, it significantly impacts UX in real-world applications. Having this capability readily available within our API interaction library is invaluable.
RTK Query: Weighing the Pros and Cons
Final Source Code
Throughout this article, we explored the practical application of RTK Query, covering testing, state retrieval, cache invalidation, and prefetching.
The high-level benefits we encountered include:
- Data fetching built upon Redux, leveraging its powerful state management.
- Centralized management of API definitions and cache invalidation strategies.
- Enhanced developer experience and maintainability through TypeScript.
However, it’s crucial to acknowledge some potential drawbacks:
- The library is under active development, implying potential API changes.
- Limited external resources: Apart from the documentation, which may not always be up-to-date, there’s a scarcity of information available.
While this practical walkthrough using the GitHub API covered substantial ground, RTK Query has much more to offer, including:
If RTK Query’s advantages have piqued your interest, I encourage you to delve deeper into these concepts. Feel free to use the RTK Query example provided as a foundation for your own explorations.