Crearea de aplicații React cu Redux Toolkit și RTK Query
Publicat: 2022-03-11Ți-ai dorit vreodată să folosești Redux cu funcții precum React Query? Acum puteți, folosind Redux Toolkit și ultima sa adăugare: RTK Query.
RTK Query este un instrument avansat de preluare a datelor și de stocare în cache pe partea clientului. Funcționalitatea sa este similară cu React Query, dar are avantajul de a fi integrat direct cu Redux. Pentru interacțiunea API, dezvoltatorii folosesc de obicei module middleware asincrone precum Thunk atunci când lucrează cu Redux. O astfel de abordare limitează flexibilitatea; astfel, dezvoltatorii React au acum o alternativă oficială din partea echipei Redux care acoperă toate nevoile avansate ale comunicării client/server de astăzi.
Acest articol demonstrează modul în care RTK Query poate fi utilizat în scenarii din lumea reală, iar fiecare pas include un link către o diferență de commit pentru a evidenția funcționalitatea adăugată. Un link către baza de cod completă apare la sfârșit.
Boilerplate și configurație
Diferența de inițializare a proiectului
În primul rând, trebuie să creăm un proiect. Acest lucru se face folosind șablonul Create React App (CRA) pentru utilizare cu TypeScript și Redux:
npx create-react-app . --template redux-typescriptAre mai multe dependențe pe care le vom solicita pe parcurs, cele mai notabile fiind:
- Redux Toolkit și RTK Query
- UI material
- Lodash
- Formik
- React Router
Include, de asemenea, posibilitatea de a oferi o configurație personalizată pentru webpack. În mod normal, CRA nu acceptă astfel de abilități decât dacă ejectați.
Inițializare
O cale mult mai sigură decât eject este să folosești ceva care poate modifica configurația, mai ales dacă acele modificări sunt mici. Acest boilerplate folosește react-app-rewired și customize-cra pentru a realiza această funcționalitate pentru a introduce o configurație personalizată babel:
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 };Acest lucru face experiența dezvoltatorului mai bună, permițând importurile. De exemplu:
import { omit } from 'lodash'; import { Box } from '@material-ui/core';Astfel de importuri au ca rezultat, de obicei, o dimensiune mai mare a pachetului, dar cu funcționalitatea de rescriere pe care am configurat-o, acestea vor funcționa astfel:
import omit from 'lodash/omit'; import Box from '@material-ui/core/Box';Configurare
Redux Setup Diff
Deoarece întreaga aplicație se bazează pe Redux, după inițializare va trebui să setăm configurația magazinului:
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;În afară de configurația standard a magazinului, vom adăuga configurație pentru o acțiune globală de resetare a stării care este utilă în aplicațiile din lumea reală, atât pentru aplicațiile în sine, cât și pentru testare:
import { createAction } from '@reduxjs/toolkit'; export const RESET_STATE_ACTION_TYPE = 'resetState'; export const resetStateAction = createAction( RESET_STATE_ACTION_TYPE, () => { return { payload: null }; } );În continuare, vom adăuga middleware personalizat pentru gestionarea răspunsurilor 401 prin simpla ștergere a magazinului:
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); };Până acum, bine. Am creat boilerplate și am configurat Redux. Acum să adăugăm câteva funcționalități.
Autentificare
Se preiau diferența de jeton de acces
Pentru simplitate, autentificarea este împărțită în trei pași:
- Adăugarea de definiții API pentru a prelua un jeton de acces
- Adăugarea de componente pentru a gestiona fluxul de autentificare web GitHub
- Finalizarea autentificării prin furnizarea de componente utilitare pentru furnizarea utilizatorului întregii aplicații
La acest pas, adăugăm posibilitatea de a prelua jetonul de acces.
Ideologia RTK Query impune ca toate definițiile API să apară într-un singur loc, ceea ce este util atunci când aveți de-a face cu aplicații la nivel de întreprindere cu mai multe puncte finale. Într-o aplicație de întreprindere, este mult mai ușor să contemplați API-ul integrat, precum și memorarea în cache a clientului, atunci când totul este într-un singur loc.
RTK Query oferă instrumente pentru generarea automată a definițiilor API folosind standardele OpenAPI sau GraphQL. Aceste instrumente sunt încă la început, dar sunt dezvoltate în mod activ. În plus, această bibliotecă este concepută pentru a oferi dezvoltatorilor o experiență excelentă cu TypeScript, care devine din ce în ce mai mult alegerea pentru aplicațiile de întreprindere datorită capacității sale de a îmbunătăți mentenabilitatea.
În cazul nostru, definițiile vor locui în folderul API. Deocamdată ne-am cerut doar asta:
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 } }); }, }), }), });Autentificarea GitHub este furnizată printr-un server de autentificare open-source, care este găzduit separat pe Heroku, datorită cerințelor API-ului GitHub.
Serverul de autentificare
Deși nu este necesar pentru acest exemplu de proiect, cititorii care doresc să găzduiască propria copie a serverului de autentificare vor trebui să:
- Creați o aplicație OAuth în GitHub pentru a-și genera propriul ID de client și secret.
- Furnizați detalii GitHub serverului de autentificare prin variabilele de mediu
GITHUB_CLIENT_IDșiGITHUB_SECRET. - Înlocuiți valoarea
baseUrla punctului final de autentificare în definițiile API de mai sus. - Pe partea React, înlocuiți parametrul
client_idîn următorul exemplu de cod.
Următorul pas este să adăugați componente care utilizează acest API. Datorită cerințelor fluxului aplicației web GitHub, vom avea nevoie de o componentă de conectare responsabilă cu redirecționarea către 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; Odată ce GitHub redirecționează înapoi la aplicația noastră, vom avea nevoie de o rută pentru a gestiona codul și a prelua access_token pe baza acestuia:
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]); Dacă ați folosit vreodată React Query, mecanismul de interacțiune cu API-ul este similar pentru RTK Query. Acest lucru oferă câteva caracteristici bune datorită integrării Redux pe care le vom observa pe măsură ce implementăm funcții suplimentare. Pentru access_token , totuși, trebuie să-l salvăm manual în magazin, trimițând o acțiune:
dispatch(authSlice.actions.updateAccessToken(accessToken));Facem acest lucru pentru capacitatea de a persista simbolul între reîncărcările paginii. Atât pentru persistență, cât și pentru capacitatea de a trimite acțiunea, trebuie să definim o configurație de magazin pentru caracteristica noastră de autentificare.
Conform convenției, Redux Toolkit se referă la acestea ca felii:
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);Mai există o cerință pentru ca codul precedent să funcționeze. Fiecare API trebuie să fie furnizat ca un reductor pentru configurarea magazinului, iar fiecare API vine cu propriul său middleware, pe care trebuie să-l includeți:
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; Asta e! Acum, aplicația noastră preia access_token și suntem gata să adăugăm mai multe funcții de autentificare.
Finalizarea autentificarea
Finalizarea diferențelor de autentificare
Următoarea listă de caracteristici pentru autentificare include:
- Posibilitatea de a prelua utilizatorul din API-ul GitHub și de a-l furniza pentru restul aplicației.
- Utilitatea de a avea rute care sunt accesibile doar atunci când sunt autentificate sau când navighezi ca oaspete.
Pentru a adăuga capacitatea de a prelua utilizatorul, vom avea nevoie de niște boilerplate API. Spre deosebire de API-ul de autentificare, API-ul GitHub va avea nevoie de capacitatea de a prelua jetonul de acces din magazinul nostru Redux și de a-l aplica la fiecare solicitare ca antet de autorizare.
În interogare RTK, care se realizează prin crearea unei interogări de bază personalizată:
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();Eu folosesc axios aici, dar pot fi folosiți și alți clienți.
Următorul pas este definirea unui API pentru preluarea informațiilor despre utilizator din 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'); }, }), }), }); Folosim aici interogarea noastră de bază personalizată, ceea ce înseamnă că fiecare solicitare din domeniul de aplicare al userApi va include un antet Autorizare. Să modificăm configurația magazinului principal, astfel încât API-ul să fie disponibil:
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;Apoi, trebuie să apelăm acest API înainte ca aplicația noastră să fie redată. Pentru simplitate, să o facem într-o manieră care seamănă cu modul în care funcționează funcționalitatea de rezoluție pentru rutele Angular, astfel încât să nu fie redat nimic până când obținem informații despre utilizator.
Absența utilizatorului poate fi, de asemenea, gestionată într-un mod mai granular, furnizând în prealabil o interfață de utilizare, astfel încât utilizatorul să aibă o primă randare semnificativă mai rapid. Acest lucru necesită mai multă gândire și muncă și cu siguranță ar trebui abordat într-o aplicație pregătită pentru producție.
Pentru a face asta, trebuie să definim o componentă 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;Ceea ce face acest lucru este simplu. Interacționează cu API-ul GitHub pentru a obține informații despre utilizator și nu redă copii înainte ca răspunsul să fie disponibil. Acum, dacă includem funcționalitatea aplicației cu această componentă, știm că informațiile despre utilizator vor fi rezolvate înainte ca orice altceva să fie redat:
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; Să trecem la partea cea mai elegantă. Acum avem capacitatea de a obține informații despre utilizator oriunde în aplicație, chiar dacă nu am salvat manual informațiile despre utilizator în magazin, așa cum am făcut cu access_token .

Cum? Prin crearea unui simplu React Hook personalizat pentru acesta:
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 oferă opțiunea useQueryState pentru fiecare punct final, ceea ce ne oferă posibilitatea de a prelua starea curentă pentru acel punct final.
De ce este acest lucru atât de important și util? Pentru că nu trebuie să scriem multă suprasarcină pentru a gestiona codul. Ca bonus, obținem o separare între datele API/client din Redux din cutie.
Utilizarea RTK Query evită bătăile de cap. Combinând preluarea datelor cu gestionarea stării, RTK Query elimină decalajul care altfel ar exista chiar dacă am folosi React Query. (Cu React Query, datele preluate trebuie să fie accesate de către componente neînrudite din stratul UI.)
Ca pas final, definim o componentă de rută personalizată standard care utilizează acest cârlig pentru a determina dacă o rută ar trebui redată sau nu:
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;Teste de autentificare Diff
Nu există nimic specific în mod inerent pentru RTK Query atunci când vine vorba de scrierea de teste pentru aplicațiile React. Personal, sunt în favoarea abordării lui Kent C. Dodds cu privire la testare și a unui stil de testare care se concentrează pe experiența utilizatorului și pe interacțiunea cu utilizatorul. Nimic nu se schimbă prea mult atunci când utilizați RTK Query.
Acestea fiind spuse, fiecare pas va include în continuare propriile teste pentru a demonstra că o aplicație scrisă cu RTK Query este perfect testabilă.
Notă: Exemplul arată părerea mea despre cum ar trebui scrise acele teste în ceea ce privește ce să testez, ce să batjocoresc și cât de multă reutilizare a codului să introduc.
Arhivele de interogări RTK
Pentru a prezenta RTK Query, vom introduce câteva caracteristici suplimentare în aplicație pentru a vedea cum funcționează în anumite scenarii și cum poate fi utilizată.
Arhivele Diff și Teste Diff
Primul lucru pe care îl vom face este să introducem o caracteristică pentru depozite. Această caracteristică va încerca să imite funcționalitatea filei Arhive pe care o puteți experimenta în GitHub. Acesta vă va vizita profilul și va avea capacitatea de a căuta depozite și de a le sorta în funcție de anumite criterii. Există multe modificări ale fișierelor introduse în acest pas. Vă încurajez să cercetați părțile care vă interesează.
Să adăugăm mai întâi definițiile API necesare pentru a acoperi funcționalitatea depozitelor:
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 });Odată ce este gata, să introducem o funcție de depozit constând din Căutare/Grilă/Paginare:
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;Interacțiunea cu API-ul Repositories este mai complexă decât ceea ce am întâlnit până acum, așa că haideți să definim cârlige personalizate care ne vor oferi capacitatea de a:
- Obțineți argumente pentru apelurile API.
- Obțineți rezultatul actual al API-ului așa cum este stocat în stare.
- Preluați date apelând punctele finale 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(); };Având acest nivel de separare ca strat de abstractizare este important în acest caz atât din perspectiva lizibilității, cât și datorită cerințelor RTK Query.
După cum probabil ați observat când am introdus un cârlig care preia datele utilizatorului utilizând useQueryState , a trebuit să furnizăm aceleași argumente pe care le-am furnizat pentru apelul API real.
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; }; Nulul pe care îl oferim ca argument este acolo, indiferent dacă numim useQuery sau useQueryState . Acest lucru este necesar deoarece RTK Query identifică și memorează în cache o informație prin argumentele care au fost utilizate pentru a prelua acele informații în primul rând.
Aceasta înseamnă că trebuie să putem obține argumentele necesare pentru un apel API separat de apelul API real în orice moment. În acest fel, îl putem folosi pentru a prelua starea stocată în cache a datelor API ori de câte ori este nevoie.
Mai este un lucru la care trebuie să fii atent în această bucată de cod din definiția noastră API:
refetchOnMountOrArgChange: 60De ce? Deoarece unul dintre punctele importante atunci când utilizați biblioteci precum RTK Query este gestionarea cache-ului clientului și invalidarea cache-ului. Acest lucru este vital și necesită, de asemenea, un efort substanțial, care poate fi dificil de furnizat, în funcție de faza de dezvoltare în care vă aflați.
Am considerat că RTK Query este foarte flexibil în acest sens. Utilizarea acestei proprietăți de configurare ne permite să:
- Dezactivați complet stocarea în cache, ceea ce este util atunci când doriți să migrați către RTK Query, evitând problemele de cache ca pas inițial.
- Introduceți memorarea în cache bazată pe timp, un mecanism simplu de invalidare de utilizat atunci când știți că unele informații pot fi stocate în cache pentru o perioadă de timp X.
Se angajează
Commit Diff și testează Diff
Acest pas adaugă mai multe funcționalități paginii de depozit, adăugând o capacitate de a vizualiza comiterile pentru fiecare depozit, de a pagina acele comite și de a filtra după ramură. De asemenea, încearcă să imite funcționalitatea pe care le-ați obține pe o pagină GitHub.
Am introdus încă două endpoint-uri pentru obținerea de ramuri și commit-uri, precum și hook-uri personalizate pentru aceste endpoint-uri, urmând stilul pe care l-am stabilit în timpul implementării depozitelor:
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]); };După ce am făcut acest lucru, acum putem îmbunătăți UX-ul prin preluarea prealabilă a datelor de comitere de îndată ce cineva trece cu mouse-ul peste numele depozitului:
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:
- Eroare de manipulare
- Mutații
- 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.
