Creazione di app React con Redux Toolkit e RTK Query

Pubblicato: 2022-03-11

Hai mai desiderato utilizzare Redux con funzionalità come React Query? Ora puoi, usando Redux Toolkit e la sua ultima aggiunta: RTK Query.

RTK Query è uno strumento avanzato per il recupero dei dati e la memorizzazione nella cache lato client. La sua funzionalità è simile a React Query ma ha il vantaggio di essere direttamente integrato con Redux. Per l'interazione API, gli sviluppatori utilizzano in genere moduli middleware asincroni come Thunk quando lavorano con Redux. Tale approccio limita la flessibilità; quindi gli sviluppatori di React ora hanno un'alternativa ufficiale dal team Redux che copre tutte le esigenze avanzate della comunicazione client/server di oggi.

Questo articolo illustra come utilizzare la query RTK in scenari reali e ogni passaggio include un collegamento a una differenza di commit per evidenziare la funzionalità aggiunta. Alla fine viene visualizzato un collegamento alla base di codice completa.

Piastra Caldaia e Configurazione

Inizializzazione progetto Diff

Per prima cosa, dobbiamo creare un progetto. Questo viene fatto utilizzando il modello Create React App (CRA) da utilizzare con TypeScript e Redux:

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

Ha diverse dipendenze di cui avremo bisogno lungo il percorso, le più importanti sono:

  • Redux Toolkit e query RTK
  • UI materiale
  • Lodash
  • Formik
  • Reagire router

Include anche la possibilità di fornire una configurazione personalizzata per il webpack. Normalmente, CRA non supporta tali abilità a meno che non venga espulso.

Inizializzazione

Un percorso molto più sicuro dell'espulsione consiste nell'utilizzare qualcosa che possa modificare la configurazione, soprattutto se tali modifiche sono piccole. Questo boilerplate utilizza react-app-rewired e customize-cra per realizzare quella funzionalità per introdurre una configurazione babel personalizzata:

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

Ciò migliora l'esperienza dello sviluppatore consentendo le importazioni. Per esempio:

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

Tali importazioni di solito comportano un aumento delle dimensioni del pacchetto, ma con la funzionalità di riscrittura che abbiamo configurato, queste funzioneranno in questo modo:

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

Configurazione

Configurazione Redux Diff

Poiché l'intera app è basata su Redux, dopo l'inizializzazione dovremo configurare la configurazione del negozio:

 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;

Oltre alla configurazione standard del negozio, aggiungeremo la configurazione per un'azione dello stato di ripristino globale che è utile nelle app del mondo reale, sia per le app stesse che per i test:

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

Successivamente, aggiungeremo un middleware personalizzato per la gestione delle risposte 401 semplicemente svuotando il negozio:

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

Fin qui tutto bene. Abbiamo creato il boilerplate e configurato Redux. Ora aggiungiamo alcune funzionalità.

Autenticazione

Recupero token di accesso Diff

L'autenticazione è suddivisa in tre passaggi per semplicità:

  • Aggiunta di definizioni API per recuperare un token di accesso
  • Aggiunta di componenti per gestire il flusso di autenticazione Web GitHub
  • Finalizzare l'autenticazione fornendo componenti di utilità per fornire all'utente l'intera app

A questo punto, aggiungiamo la possibilità di recuperare il token di accesso.

L'ideologia della query RTK impone che tutte le definizioni API vengano visualizzate in un'unica posizione, il che è utile quando si tratta di applicazioni di livello aziendale con diversi endpoint. In un'applicazione aziendale, è molto più semplice considerare l'API integrata, così come la memorizzazione nella cache del client, quando tutto è in un unico posto.

RTK Query offre strumenti per la generazione automatica di definizioni API utilizzando gli standard OpenAPI o GraphQL. Questi strumenti sono ancora agli inizi, ma sono attivamente sviluppati. Inoltre, questa libreria è progettata per fornire un'esperienza di sviluppo eccellente con TypeScript, che sta diventando sempre più la scelta per le applicazioni aziendali grazie alla sua capacità di migliorare la manutenibilità.

Nel nostro caso, le definizioni risiederanno nella cartella API. Per ora abbiamo richiesto solo questo:

 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'autenticazione GitHub viene fornita tramite un server di autenticazione open source, ospitato separatamente su Heroku a causa dei requisiti dell'API GitHub.

Il server di autenticazione

Sebbene non sia richiesto per questo progetto di esempio, i lettori che desiderano ospitare la propria copia del server di autenticazione dovranno:

  1. Crea un'app OAuth in GitHub per generare il proprio ID client e segreto.
  2. Fornisci i dettagli di GitHub al server di autenticazione tramite le variabili di ambiente GITHUB_CLIENT_ID e GITHUB_SECRET .
  3. Sostituisci il valore baseUrl dell'endpoint di autenticazione nelle definizioni API precedenti.
  4. Sul lato React, sostituire il parametro client_id nell'esempio di codice successivo.

Il passaggio successivo consiste nell'aggiungere componenti che utilizzano questa API. A causa dei requisiti del flusso dell'applicazione Web GitHub, avremo bisogno di un componente di accesso responsabile del reindirizzamento a 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;

Una volta che GitHub reindirizza alla nostra app, avremo bisogno di un percorso per gestire il codice e recuperare access_token in base ad esso:

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

Se hai mai utilizzato React Query, il meccanismo per interagire con l'API è simile per RTK Query. Ciò fornisce alcune funzionalità ordinate grazie all'integrazione di Redux che osserveremo mentre implementiamo funzionalità aggiuntive. Per access_token , tuttavia, dobbiamo comunque salvarlo manualmente nel negozio inviando un'azione:

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

Lo facciamo per la possibilità di mantenere il token tra i ricaricamenti della pagina. Sia per la persistenza che per la possibilità di inviare l'azione, dobbiamo definire una configurazione del negozio per la nostra funzione di autenticazione.

Per convenzione, Redux Toolkit si riferisce a queste come sezioni:

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

C'è un altro requisito per il funzionamento del codice precedente. Ogni API deve essere fornita come riduttore per la configurazione del negozio e ogni API viene fornita con il proprio middleware, che devi includere:

 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;

Questo è tutto! Ora la nostra app sta recuperando access_token e siamo pronti per aggiungere ulteriori funzionalità di autenticazione.

Completamento dell'autenticazione

Autenticazione diff

Il prossimo elenco di funzionalità per l'autenticazione include:

  • La possibilità di recuperare l'utente dall'API GitHub e fornirlo per il resto dell'app.
  • L'utilità per avere percorsi accessibili solo se autenticati o durante la navigazione come ospite.

Per aggiungere la possibilità di recuperare l'utente, avremo bisogno di alcune API standard. A differenza dell'API di autenticazione, l'API GitHub avrà bisogno della capacità di recuperare il token di accesso dal nostro negozio Redux e applicarlo a ogni richiesta come intestazione di autorizzazione.

Nella query RTK che si ottiene creando una query di base personalizzata:

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

Sto usando axios qui, ma è possibile utilizzare anche altri client.

Il passaggio successivo consiste nel definire un'API per il recupero delle informazioni utente da 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'); }, }), }), });

Utilizziamo qui la nostra query di base personalizzata, il che significa che ogni richiesta nell'ambito di userApi includerà un'intestazione di autorizzazione. Modifichiamo la configurazione del negozio principale in modo che l'API sia disponibile:

 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;

Successivamente, dobbiamo chiamare questa API prima che la nostra app venga renderizzata. Per semplicità, eseguiamo l'operazione in modo simile a come funziona la funzionalità di risoluzione per le route angolari in modo che non venga eseguito il rendering fino a quando non otteniamo le informazioni sull'utente.

L'assenza dell'utente può anche essere gestita in modo più granulare, fornendo in anticipo un'interfaccia utente in modo che l'utente abbia un primo rendering significativo più rapidamente. Ciò richiede più riflessione e lavoro e dovrebbe sicuramente essere affrontato in un'app pronta per la produzione.

Per fare ciò, dobbiamo definire un componente 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;

Ciò che fa è semplice. Interagisce con l'API GitHub per ottenere informazioni sull'utente e non esegue il rendering dei bambini prima che la risposta sia disponibile. Ora, se avvolgiamo la funzionalità dell'app con questo componente, sappiamo che le informazioni sull'utente verranno risolte prima del rendering di qualsiasi altra cosa:

 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;

Passiamo alla parte più elegante. Ora abbiamo la possibilità di ottenere le informazioni sull'utente in qualsiasi punto dell'app, anche se non le abbiamo salvate manualmente in negozio come abbiamo fatto con access_token .

Come? Creando un semplice React Hook personalizzato per esso:

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

Query RTK fornisce l'opzione useQueryState per ogni endpoint, che ci dà la possibilità di recuperare lo stato corrente per quell'endpoint.

Perché è così importante e utile? Perché non dobbiamo scrivere molto sovraccarico per gestire il codice. Come bonus, otteniamo immediatamente una separazione tra i dati API/client in Redux.

L'utilizzo di RTK Query evita il fastidio. Combinando il recupero dei dati con la gestione dello stato, RTK Query elimina il divario che altrimenti sarebbe presente anche se dovessimo utilizzare React Query. (Con React Query, è necessario accedere ai dati recuperati da componenti non correlati sul livello dell'interfaccia utente.)

Come passaggio finale, definiamo un componente di route personalizzato standard che utilizza questo hook per determinare se una route deve essere renderizzata o meno:

 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;

Test di autenticazione Diff

Non c'è nulla di intrinsecamente specifico per RTK Query quando si tratta di scrivere test per le app React. Personalmente, sono favorevole all'approccio di Kent C. Dodds ai test e a uno stile di test incentrato sull'esperienza dell'utente e sull'interazione dell'utente. Non cambia molto quando si utilizza la query RTK.

Detto questo, ogni passaggio includerà ancora i propri test per dimostrare che un'app scritta con RTK Query è perfettamente testabile.

Nota: l'esempio mostra la mia opinione su come dovrebbero essere scritti quei test riguardo a cosa testare, cosa deridere e quanta riutilizzabilità del codice introdurre.

Repository di query RTK

Per mostrare RTK Query, introdurremo alcune funzionalità aggiuntive nell'applicazione per vedere come si comporta in determinati scenari e come può essere utilizzata.

Repository Diff e Test Diff

La prima cosa che faremo è introdurre una funzionalità per i repository. Questa funzione proverà a imitare la funzionalità della scheda Repository che puoi sperimentare in GitHub. Visiterà il tuo profilo e avrà la possibilità di cercare repository e ordinarli in base a determinati criteri. Ci sono molte modifiche ai file introdotte in questo passaggio. Ti incoraggio a scavare nelle parti che ti interessano.

Aggiungiamo prima le definizioni API necessarie per coprire la funzionalità dei repository:

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

Una volta che è pronto, introduciamo una funzionalità di Repository composta da Ricerca/Griglia/Paginazione:

 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'interazione con l'API Repositories è più complessa di quella che abbiamo incontrato finora, quindi definiamo hook personalizzati che ci forniranno la possibilità di:

  • Ottieni argomenti per le chiamate API.
  • Ottieni il risultato dell'API corrente come archiviato nello stato.
  • Recupera i dati chiamando gli endpoint 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(); };

Avere questo livello di separazione come livello di astrazione è importante in questo caso sia dal punto di vista della leggibilità che per i requisiti di RTK Query.

Come avrai notato quando abbiamo introdotto un hook che recupera i dati dell'utente utilizzando useQueryState , abbiamo dovuto fornire gli stessi argomenti che abbiamo fornito per la chiamata API effettiva.

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

Quel null che forniamo come argomento è presente sia che chiamiamo useQuery o useQueryState . Ciò è necessario perché RTK Query identifica e memorizza nella cache un'informazione in base agli argomenti utilizzati per recuperare tali informazioni in primo luogo.

Ciò significa che dobbiamo essere in grado di ottenere gli argomenti richiesti per una chiamata API separatamente dalla chiamata API effettiva in qualsiasi momento. In questo modo, possiamo usarlo per recuperare lo stato memorizzato nella cache dei dati API ogni volta che ne abbiamo bisogno.

C'è un'altra cosa a cui devi prestare attenzione in questo pezzo di codice nella nostra definizione API:

 refetchOnMountOrArgChange: 60

Come mai? Perché uno dei punti importanti quando si utilizzano librerie come RTK Query è la gestione della cache del client e l'invalidazione della cache. Questo è vitale e richiede anche un notevole sforzo, che potrebbe essere difficile da fornire a seconda della fase di sviluppo in cui ti trovi.

Ho trovato RTK Query molto flessibile al riguardo. L'utilizzo di questa proprietà di configurazione ci consente di:

  • Disabilita del tutto la memorizzazione nella cache, che è utile quando vuoi migrare verso RTK Query, evitando problemi di cache come passaggio iniziale.
  • Introduci la memorizzazione nella cache basata sul tempo, un semplice meccanismo di invalidamento da utilizzare quando sai che alcune informazioni possono essere memorizzate nella cache per un periodo di tempo X.

Si impegna

Commit Diff e Test Diff

Questo passaggio aggiunge più funzionalità alla pagina del repository aggiungendo la possibilità di visualizzare i commit per ciascun repository, impaginare tali commit e filtrare per ramo. Cerca anche di imitare la funzionalità che otterresti su una pagina GitHub.

Abbiamo introdotto altri due endpoint per ottenere branch e commit, nonché hook personalizzati per questi endpoint, seguendo lo stile che abbiamo stabilito durante l'implementazione dei repository:

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

Fatto ciò, ora possiamo migliorare l'UX precaricando i dati dei commit non appena qualcuno passa sopra il nome del repository:

 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:

  • Gestione degli errori
  • Mutazioni
  • 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.