Création d'applications React avec Redux Toolkit et RTK Query

Publié: 2022-03-11

Avez-vous déjà voulu utiliser Redux avec des fonctionnalités telles que celles fournies par React Query ? C'est désormais possible, en utilisant Redux Toolkit et son dernier ajout : RTK Query.

RTK Query est un outil avancé de récupération de données et de mise en cache côté client. Sa fonctionnalité est similaire à React Query mais elle a l'avantage d'être directement intégrée à Redux. Pour l'interaction avec l'API, les développeurs utilisent généralement des modules middleware asynchrones comme Thunk lorsqu'ils travaillent avec Redux. Une telle approche limite la flexibilité ; Ainsi, les développeurs de React disposent désormais d'une alternative officielle de l'équipe Redux qui couvre tous les besoins avancés de la communication client/serveur d'aujourd'hui.

Cet article montre comment RTK Query peut être utilisé dans des scénarios réels, et chaque étape inclut un lien vers un diff de validation pour mettre en évidence les fonctionnalités ajoutées. Un lien vers la base de code complète apparaît à la fin.

Récapitulatif et configuration

Diff d'initialisation du projet

Tout d'abord, nous devons créer un projet. Cela se fait à l'aide du modèle Create React App (CRA) à utiliser avec TypeScript et Redux :

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

Il a plusieurs dépendances dont nous aurons besoin en cours de route, les plus notables étant :

  • Boîte à outils Redux et requête RTK
  • Interface utilisateur matérielle
  • Lodash
  • Formulaire
  • Réagir Routeur

Il inclut également la possibilité de fournir une configuration personnalisée pour Webpack. Normalement, CRA ne prend pas en charge de telles capacités, sauf si vous vous éjectez.

Initialisation

Une voie beaucoup plus sûre que l'éjection consiste à utiliser quelque chose qui peut modifier la configuration, surtout si ces modifications sont petites. Ce passe-partout utilise react-app-rewired et customize-cra pour accomplir cette fonctionnalité afin d'introduire une configuration babel personnalisée :

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

Cela améliore l'expérience du développeur en autorisant les importations. Par exemple:

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

De telles importations entraînent généralement une augmentation de la taille du bundle, mais avec la fonctionnalité de réécriture que nous avons configurée, celles-ci fonctionneront comme suit :

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

Configuration

Différence de configuration Redux

Étant donné que toute l'application est basée sur Redux, après l'initialisation, nous devrons configurer la configuration du magasin :

 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;

Outre la configuration standard du magasin, nous ajouterons une configuration pour une action d'état de réinitialisation globale qui s'avère pratique dans les applications du monde réel, à la fois pour les applications elles-mêmes et pour les tests :

 import { createAction } from '@reduxjs/toolkit'; export const RESET_STATE_ACTION_TYPE = 'resetState'; export const resetStateAction = createAction( RESET_STATE_ACTION_TYPE, () => { return { payload: null }; } );

Ensuite, nous ajouterons un middleware personnalisé pour gérer les réponses 401 en effaçant simplement le magasin :

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

Jusqu'ici tout va bien. Nous avons créé le passe-partout et configuré Redux. Ajoutons maintenant quelques fonctionnalités.

Authentification

Récupération du jeton d'accès Diff

L'authentification se décompose en trois étapes pour plus de simplicité :

  • Ajouter des définitions d'API pour récupérer un jeton d'accès
  • Ajout de composants pour gérer le flux d'authentification Web GitHub
  • Finalisation de l'authentification en fournissant des composants utilitaires pour fournir l'utilisateur à l'ensemble de l'application

A cette étape, nous ajoutons la possibilité de récupérer le jeton d'accès.

L'idéologie RTK Query dicte que toutes les définitions d'API apparaissent au même endroit, ce qui est pratique lorsqu'il s'agit d'applications d'entreprise avec plusieurs points de terminaison. Dans une application d'entreprise, il est beaucoup plus facile d'envisager l'API intégrée, ainsi que la mise en cache du client, lorsque tout est au même endroit.

RTK Query propose des outils pour générer automatiquement des définitions d'API à l'aide des normes OpenAPI ou de GraphQL. Ces outils en sont encore à leurs balbutiements, mais ils sont activement développés. De plus, cette bibliothèque est conçue pour offrir une excellente expérience de développement avec TypeScript, qui devient de plus en plus le choix des applications d'entreprise en raison de sa capacité à améliorer la maintenabilité.

Dans notre cas, les définitions résideront dans le dossier API. Pour l'instant, nous n'avons demandé que ceci :

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

L'authentification GitHub est fournie via un serveur d'authentification open source, qui est hébergé séparément sur Heroku en raison des exigences de l'API GitHub.

Le serveur d'authentification

Bien que cela ne soit pas requis pour cet exemple de projet, les lecteurs souhaitant héberger leur propre copie du serveur d'authentification devront :

  1. Créez une application OAuth dans GitHub pour générer leur propre ID client et secret.
  2. Fournissez les détails GitHub au serveur d'authentification via les variables d'environnement GITHUB_CLIENT_ID et GITHUB_SECRET .
  3. Remplacez la valeur baseUrl du point de terminaison d'authentification dans les définitions d'API ci-dessus.
  4. Du côté de React, remplacez le paramètre client_id dans l'exemple de code suivant.

L'étape suivante consiste à ajouter des composants qui utilisent cette API. En raison des exigences du flux d'application Web GitHub, nous aurons besoin d'un composant de connexion responsable de la redirection vers 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-test aria-label="Login Link" > <GitHubIcon fontSize="large"/> </Link> </Grid> </Grid> </Box> </Container> ); }; export default Login;

Une fois que GitHub sera redirigé vers notre application, nous aurons besoin d'une route pour gérer le code et récupérer access_token en fonction de celui-ci :

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

Si vous avez déjà utilisé React Query, le mécanisme d'interaction avec l'API est similaire pour RTK Query. Cela fournit des fonctionnalités intéressantes grâce à l'intégration de Redux que nous observerons au fur et à mesure que nous implémenterons des fonctionnalités supplémentaires. Pour access_token , cependant, nous devons toujours l'enregistrer manuellement dans le magasin en envoyant une action :

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

Nous faisons cela pour pouvoir conserver le jeton entre les rechargements de page. À la fois pour la persistance et la capacité à envoyer l'action, nous devons définir une configuration de magasin pour notre fonctionnalité d'authentification.

Par convention, Redux Toolkit les appelle des tranches :

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

Il y a une autre exigence pour que le code précédent fonctionne. Chaque API doit être fournie en tant que réducteur pour la configuration du magasin, et chaque API est livrée avec son propre middleware, que vous devez inclure :

 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;

C'est ça! Maintenant, notre application récupère access_token et nous sommes prêts à ajouter plus de fonctionnalités d'authentification.

Achèvement de l'authentification

Terminer l'authentification Diff

La prochaine liste de fonctionnalités pour l'authentification comprend :

  • La possibilité de récupérer l'utilisateur à partir de l'API GitHub et de le fournir pour le reste de l'application.
  • L'utilitaire pour avoir des itinéraires qui ne sont accessibles qu'une fois authentifiés ou lors de la navigation en tant qu'invité.

Pour ajouter la possibilité de récupérer l'utilisateur, nous aurons besoin d'une API passe-partout. Contrairement à l'API d'authentification, l'API GitHub devra pouvoir récupérer le jeton d'accès de notre magasin Redux et l'appliquer à chaque demande en tant qu'en-tête d'autorisation.

Dans RTK Query, cela est obtenu en créant une requête de base personnalisée :

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

J'utilise axios ici, mais d'autres clients peuvent également être utilisés.

L'étape suivante consiste à définir une API pour récupérer les informations utilisateur de 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'); }, }), }), });

Nous utilisons ici notre requête de base personnalisée, ce qui signifie que chaque requête dans le cadre de userApi inclura un en-tête d'autorisation. Modifions la configuration du magasin principal pour que l'API soit disponible :

 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;

Ensuite, nous devons appeler cette API avant que notre application ne soit rendue. Pour plus de simplicité, faisons-le d'une manière qui ressemble à la façon dont la fonctionnalité de résolution fonctionne pour les routes angulaires afin que rien ne soit rendu jusqu'à ce que nous obtenions des informations sur l'utilisateur.

L'absence de l'utilisateur peut également être gérée de manière plus granulaire, en fournissant au préalable une interface utilisateur afin que l'utilisateur dispose plus rapidement d'un premier rendu significatif. Cela nécessite plus de réflexion et de travail, et devrait certainement être traité dans une application prête pour la production.

Pour ce faire, nous devons définir un composant middleware :

 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;

Ce que cela fait est simple. Il interagit avec l'API GitHub pour obtenir des informations sur l'utilisateur et ne rend pas les enfants avant que la réponse ne soit disponible. Maintenant, si nous encapsulons la fonctionnalité de l'application avec ce composant, nous savons que les informations de l'utilisateur seront résolues avant que quoi que ce soit d'autre ne s'affiche :

 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;

Passons à la partie la plus élégante. Nous avons maintenant la possibilité d'obtenir des informations utilisateur n'importe où dans l'application, même si nous n'avons pas enregistré manuellement ces informations utilisateur en magasin comme nous l'avons fait avec access_token .

Comment? En créant un simple React Hook personnalisé pour celui-ci :

 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 fournit l'option useQueryState pour chaque point de terminaison, ce qui nous permet de récupérer l'état actuel de ce point de terminaison.

Pourquoi est-ce si important et utile ? Parce que nous n'avons pas besoin d'écrire beaucoup de surcharge pour gérer le code. En prime, nous obtenons une séparation entre les données API/client dans Redux prêtes à l'emploi.

L'utilisation de RTK Query évite les tracas. En combinant la récupération de données avec la gestion de l'état, RTK Query élimine l'écart qui existerait autrement même si nous devions utiliser React Query. (Avec React Query, les données récupérées doivent être accessibles par des composants non liés sur la couche d'interface utilisateur.)

Dans une dernière étape, nous définissons un composant de route personnalisé standard qui utilise ce crochet pour déterminer si une route doit être rendue ou non :

 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;

Différence des tests d'authentification

Il n'y a rien d'intrinsèquement spécifique à RTK Query lorsqu'il s'agit d'écrire des tests pour les applications React. Personnellement, je suis en faveur de l'approche de test de Kent C. Dodds et d'un style de test qui se concentre sur l'expérience utilisateur et l'interaction avec l'utilisateur. Rien ne change beaucoup lors de l'utilisation de la requête RTK.

Cela étant dit, chaque étape comprendra toujours ses propres tests pour démontrer qu'une application écrite avec RTK Query est parfaitement testable.

Remarque : L'exemple montre mon point de vue sur la façon dont ces tests doivent être écrits en ce qui concerne ce qu'il faut tester, ce qu'il faut simuler et la quantité de réutilisabilité du code à introduire.

Référentiels de requêtes RTK

Pour présenter RTK Query, nous présenterons quelques fonctionnalités supplémentaires à l'application pour voir comment elle fonctionne dans certains scénarios et comment elle peut être utilisée.

Référentiels Diff et Tests Diff

La première chose que nous allons faire est d'introduire une fonctionnalité pour les référentiels. Cette fonctionnalité tentera d'imiter la fonctionnalité de l'onglet Dépôts que vous pouvez découvrir dans GitHub. Il visitera votre profil et aura la possibilité de rechercher des référentiels et de les trier en fonction de certains critères. De nombreuses modifications de fichiers ont été introduites dans cette étape. Je vous encourage à creuser dans les parties qui vous intéressent.

Ajoutons d'abord les définitions d'API requises pour couvrir la fonctionnalité des référentiels :

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

Une fois que c'est prêt, introduisons une fonctionnalité de référentiel consistant en Recherche/Grille/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;

L'interaction avec l'API Repositories est plus complexe que ce que nous avons rencontré jusqu'à présent, alors définissons des crochets personnalisés qui nous permettront de :

  • Obtenez des arguments pour les appels d'API.
  • Obtenez le résultat actuel de l'API tel qu'il est stocké dans l'état.
  • Récupérez des données en appelant des points de terminaison d'API.
 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(); };

Avoir ce niveau de séparation en tant que couche d'abstraction est important dans ce cas, à la fois du point de vue de la lisibilité et en raison des exigences de la requête RTK.

Comme vous l'avez peut-être remarqué lorsque nous avons introduit un hook qui récupère les données utilisateur à l'aide de useQueryState , nous avons dû fournir les mêmes arguments que nous avons fournis pour l'appel d'API réel.

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

Ce null que nous fournissons comme argument est là, que nous useQuery ou useQueryState . Cela est nécessaire car RTK Query identifie et met en cache une information par les arguments qui ont été utilisés pour récupérer cette information en premier lieu.

Cela signifie que nous devons être en mesure d'obtenir les arguments requis pour un appel d'API séparément de l'appel d'API réel à tout moment. De cette façon, nous pouvons l'utiliser pour récupérer l'état mis en cache des données de l'API chaque fois que nous en avons besoin.

Il y a encore une chose à laquelle vous devez faire attention dans ce morceau de code de notre définition d'API :

 refetchOnMountOrArgChange: 60

Pourquoi? Parce que l'un des points importants lors de l'utilisation de bibliothèques telles que RTK Query est la gestion du cache client et de l'invalidation du cache. Ceci est vital et demande également un effort conséquent, qui peut être difficile à fournir selon la phase de développement dans laquelle vous vous trouvez.

J'ai trouvé RTK Query très flexible à cet égard. L'utilisation de cette propriété de configuration nous permet de :

  • Désactivez complètement la mise en cache, ce qui est pratique lorsque vous souhaitez migrer vers RTK Query, en évitant les problèmes de cache dans un premier temps.
  • Introduisez la mise en cache basée sur le temps, un mécanisme d'invalidation simple à utiliser lorsque vous savez que certaines informations peuvent être mises en cache pendant X durée.

Commit

Valide Diff et teste Diff

Cette étape ajoute plus de fonctionnalités à la page du référentiel en ajoutant la possibilité d'afficher les commits pour chaque référentiel, de paginer ces commits et de filtrer par branche. Il essaie également d'imiter la fonctionnalité que vous obtiendriez sur une page GitHub.

Nous avons introduit deux points de terminaison supplémentaires pour obtenir des branches et des commits, ainsi que des crochets personnalisés pour ces points de terminaison, en suivant le style que nous avons établi lors de la mise en œuvre des référentiels :

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

Cela fait, nous pouvons maintenant améliorer l'UX en préchargeant les données des commits dès que quelqu'un survole le nom du référentiel :

 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 walkthrough using the GitHub API, but there is much more to RTK Query, such as:

  • La gestion des erreurs
  • mutation
  • Polling
  • Optimistic Updates

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