Tworzenie aplikacji React z Redux Toolkit i RTK Query
Opublikowany: 2022-03-11Czy kiedykolwiek chciałeś używać Redux z funkcjami takimi jak React Query? Teraz możesz, korzystając z Redux Toolkit i jego najnowszego dodatku: RTK Query.
RTK Query to zaawansowane narzędzie do pobierania danych i buforowania po stronie klienta. Jego funkcjonalność jest podobna do React Query, ale ma tę zaletę, że jest bezpośrednio zintegrowana z Redux. W przypadku interakcji z interfejsem API programiści zwykle używają modułów oprogramowania pośredniego asynchronicznego, takich jak Thunk, podczas pracy z Redux. Takie podejście ogranicza elastyczność; w ten sposób deweloperzy React mają teraz oficjalną alternatywę od zespołu Redux, która pokrywa wszystkie zaawansowane potrzeby dzisiejszej komunikacji klient/serwer.
W tym artykule pokazano, jak RTK Query może być używane w rzeczywistych scenariuszach, a każdy krok zawiera łącze do porównania z zatwierdzeniem, aby wyróżnić dodane funkcje. Na końcu pojawia się link do pełnej bazy kodu.
Płyta kotłowa i konfiguracja
Różnica inicjalizacji projektu
Najpierw musimy stworzyć projekt. Odbywa się to za pomocą szablonu Create React App (CRA) do użytku z TypeScript i Redux:
npx create-react-app . --template redux-typescriptMa kilka zależności, których będziemy potrzebować po drodze, z których najważniejsze to:
- Zestaw narzędzi Redux i zapytanie RTK
- Interfejs materiałów
- Lodasza
- Formik
- Reaguj na router
Zawiera również możliwość dostarczenia niestandardowej konfiguracji dla webpacka. Zwykle CRA nie obsługuje takich zdolności, chyba że się wyrzucisz.
Inicjalizacja
O wiele bezpieczniejszą drogą niż eject jest użycie czegoś, co może zmodyfikować konfigurację, zwłaszcza jeśli te modyfikacje są niewielkie. Ten szablon wykorzystuje rewired-app-rewired i custom-cra, aby osiągnąć tę funkcjonalność i wprowadzić niestandardową konfigurację 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 };To sprawia, że programista jest lepszy, umożliwiając importowanie. Na przykład:
import { omit } from 'lodash'; import { Box } from '@material-ui/core';Takie importy zwykle skutkują zwiększonym rozmiarem pakietu, ale dzięki skonfigurowanej przez nas funkcji przepisywania będą one działać tak:
import omit from 'lodash/omit'; import Box from '@material-ui/core/Box';Konfiguracja
Różnica konfiguracji ponownego dodawania
Ponieważ cała aplikacja jest oparta na Redux, po uruchomieniu będziemy musieli skonfigurować sklep:
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;Oprócz standardowej konfiguracji sklepu, dodamy konfigurację dla akcji globalnego resetowania stanu, która przydaje się w rzeczywistych aplikacjach, zarówno dla samych aplikacji, jak i do testowania:
import { createAction } from '@reduxjs/toolkit'; export const RESET_STATE_ACTION_TYPE = 'resetState'; export const resetStateAction = createAction( RESET_STATE_ACTION_TYPE, () => { return { payload: null }; } );Następnie dodamy niestandardowe oprogramowanie pośredniczące do obsługi 401 odpowiedzi poprzez proste wyczyszczenie sklepu:
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); };Na razie w porządku. Stworzyliśmy schemat i skonfigurowaliśmy Redux. Dodajmy teraz trochę funkcjonalności.
Uwierzytelnianie
Pobieranie różnicy tokenów dostępu
Uwierzytelnianie jest podzielone na trzy kroki dla uproszczenia:
- Dodawanie definicji API w celu pobrania tokena dostępu
- Dodawanie komponentów do obsługi przepływu uwierzytelniania internetowego GitHub
- Finalizowanie uwierzytelniania poprzez dostarczenie komponentów narzędziowych zapewniających użytkownikowi dostęp do całej aplikacji
Na tym etapie dodajemy możliwość pobrania tokena dostępu.
Ideologia zapytań RTK nakazuje, aby wszystkie definicje API pojawiały się w jednym miejscu, co jest przydatne w przypadku aplikacji na poziomie przedsiębiorstwa z kilkoma punktami końcowymi. W aplikacji korporacyjnej znacznie łatwiej jest rozważyć zintegrowane API, a także buforowanie klienta, gdy wszystko jest w jednym miejscu.
RTK Query zawiera narzędzia do automatycznego generowania definicji API przy użyciu standardów OpenAPI lub GraphQL. Narzędzia te są jeszcze w powijakach, ale są aktywnie rozwijane. Ponadto ta biblioteka została zaprojektowana, aby zapewnić doskonałe wrażenia programistyczne z TypeScript, który jest coraz częściej wybierany dla aplikacji korporacyjnych ze względu na jego zdolność do poprawy łatwości konserwacji.
W naszym przypadku definicje będą znajdować się w folderze API. Na razie wymagamy tylko tego:
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 } }); }, }), }), });Uwierzytelnianie GitHub jest dostarczane za pośrednictwem serwera uwierzytelniania typu open source, który jest hostowany oddzielnie w Heroku ze względu na wymagania interfejsu API GitHub.
Serwer uwierzytelniania
Chociaż nie jest to wymagane w tym przykładowym projekcie, czytelnicy, którzy chcą hostować własną kopię serwera uwierzytelniania, będą musieli:
- Utwórz aplikację OAuth w GitHub, aby wygenerować własny identyfikator klienta i klucz tajny.
- Podaj szczegóły usługi GitHub serwerowi uwierzytelniania za pomocą zmiennych środowiskowych
GITHUB_CLIENT_IDiGITHUB_SECRET. - Zastąp wartość
baseUrlpunktu końcowego uwierzytelniania w powyższych definicjach interfejsu API. - Po stronie React zastąp parametr
client_idw następnym przykładzie kodu.
Następnym krokiem jest dodanie komponentów korzystających z tego interfejsu API. Ze względu na wymagania przepływu aplikacji webowych na GitHubie, będziemy potrzebować komponentu logowania odpowiedzialnego za przekierowanie do GitHuba:
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; Gdy GitHub przekieruje z powrotem do naszej aplikacji, będziemy potrzebować trasy do obsługi kodu i pobrania na jej podstawie access_token :
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]); Jeśli kiedykolwiek korzystałeś z React Query, mechanizm interakcji z API jest podobny do RTK Query. Zapewnia to kilka fajnych funkcji dzięki integracji Redux, które będziemy obserwować, gdy wdrażamy dodatkowe funkcje. Jednak w przypadku access_token nadal musimy zapisać go w sklepie ręcznie, wysyłając akcję:
dispatch(authSlice.actions.updateAccessToken(accessToken));Robimy to, aby umożliwić utrzymywanie tokena między kolejnymi ładowaniami strony. Zarówno dla trwałości, jak i możliwości wysłania akcji, musimy zdefiniować konfigurację sklepu dla naszej funkcji uwierzytelniania.
Zgodnie z konwencją Redux Toolkit nazywa je wycinkami:
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);Jest jeszcze jedno wymaganie, aby poprzedni kod działał. Każdy interfejs API musi być dostarczony jako reduktor konfiguracji sklepu, a każdy interfejs API jest dostarczany z własnym oprogramowaniem pośredniczącym, które należy uwzględnić:
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; Otóż to! Teraz nasza aplikacja pobiera access_token i jesteśmy gotowi dodać do niej więcej funkcji uwierzytelniania.
Zakończenie uwierzytelniania
Ukończenie różnicy uwierzytelniania
Kolejna lista funkcji do uwierzytelniania obejmuje:
- Możliwość pobrania użytkownika z GitHub API i udostępnienia go dla reszty aplikacji.
- Narzędzie do tworzenia tras, które są dostępne tylko po uwierzytelnieniu lub podczas przeglądania jako gość.
Aby dodać możliwość pobierania użytkownika, będziemy potrzebować pewnego szablonu API. W przeciwieństwie do interfejsu API uwierzytelniania, interfejs API GitHub będzie wymagał możliwości pobrania tokena dostępu z naszego sklepu Redux i zastosowania go do każdego żądania jako nagłówka autoryzacji.
W zapytaniu RTK uzyskuje się to poprzez utworzenie niestandardowego zapytania podstawowego:
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();Używam tutaj axios, ale można też używać innych klientów.
Następnym krokiem jest zdefiniowanie API do pobierania informacji o użytkowniku z 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'); }, }), }), }); Używamy tutaj naszego niestandardowego zapytania podstawowego, co oznacza, że każde żądanie w zakresie userApi będzie zawierało nagłówek Authorization. Dostosujmy konfigurację głównego sklepu, aby API było dostępne:
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;Następnie musimy wywołać to API przed wyrenderowaniem naszej aplikacji. Dla uproszczenia zróbmy to w sposób, który przypomina działanie funkcji rozwiązywania dla tras kątowych, tak aby nic nie było renderowane, dopóki nie otrzymamy informacji o użytkowniku.
Nieobecność użytkownika można również obsłużyć w bardziej szczegółowy sposób, zapewniając wcześniej interfejs użytkownika, aby użytkownik mógł szybciej wykonać pierwsze znaczące renderowanie. Wymaga to więcej przemyśleń i pracy i zdecydowanie powinno być rozwiązane w aplikacji gotowej do produkcji.
W tym celu musimy zdefiniować komponent oprogramowania pośredniczącego:
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;To, co to robi, jest proste. Współdziała z interfejsem API GitHub, aby uzyskać informacje o użytkowniku i nie renderuje elementów podrzędnych przed udostępnieniem odpowiedzi. Teraz, jeśli opakujemy funkcjonalność aplikacji tym komponentem, wiemy, że informacje o użytkowniku zostaną rozwiązane, zanim cokolwiek innego się wyrenderuje:
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; Przejdźmy do najbardziej eleganckiej części. Mamy teraz możliwość uzyskania informacji o użytkowniku z dowolnego miejsca w aplikacji, mimo że nie zapisaliśmy ich ręcznie w sklepie, jak to zrobiliśmy w przypadku access_token .

W jaki sposób? Tworząc dla niego prosty, niestandardowy hak React:
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 udostępnia opcję useQueryState dla każdego punktu końcowego, co daje nam możliwość pobrania aktualnego stanu tego punktu końcowego.
Dlaczego jest to tak ważne i przydatne? Ponieważ nie musimy pisać dużo narzutów, aby zarządzać kodem. Jako bonus otrzymujemy separację między API / danymi klienta w Redux po wyjęciu z pudełka.
Korzystanie z zapytania RTK pozwala uniknąć kłopotów. Łącząc pobieranie danych z zarządzaniem stanem, RTK Query eliminuje lukę, która w innym przypadku istniałaby, nawet gdybyśmy korzystali z React Query. (Dzięki React Query dostęp do pobranych danych musi mieć niepowiązane komponenty w warstwie interfejsu użytkownika).
W ostatnim kroku definiujemy standardowy komponent niestandardowej trasy, który używa tego haka do określenia, czy trasa powinna być renderowana, czy nie:
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;Testy uwierzytelniania różnią się
Nie ma nic nieodłącznie specyficznego dla RTK Query, jeśli chodzi o pisanie testów dla aplikacji React. Osobiście jestem zwolennikiem podejścia Kenta C. Doddsa do testowania i stylu testowania, który koncentruje się na doświadczeniu użytkownika i interakcji z użytkownikiem. Nic się nie zmienia podczas korzystania z RTK Query.
To powiedziawszy, każdy krok nadal będzie zawierał własne testy, aby wykazać, że aplikacja napisana za pomocą RTK Query jest doskonale testowalna.
Uwaga: Przykład pokazuje moje podejście do tego, jak te testy powinny być napisane w odniesieniu do tego, co testować, co kpić i ile wprowadzić ponownego wykorzystania kodu.
Repozytoria zapytań RTK
Aby zaprezentować RTK Query, wprowadzimy do aplikacji kilka dodatkowych funkcji, aby zobaczyć, jak działa w określonych scenariuszach i jak można z niej korzystać.
Repozytoria Różnice i testy Różnic
Pierwszą rzeczą, którą zrobimy, jest wprowadzenie funkcji dla repozytoriów. Ta funkcja spróbuje naśladować funkcjonalność karty Repozytoria, z której możesz korzystać w GitHub. Odwiedzi Twój profil i będzie miał możliwość wyszukiwania repozytoriów i sortowania ich według określonych kryteriów. W tym kroku wprowadzono wiele zmian w plikach. Zachęcam do zagłębienia się w interesujące Cię części.
Dodajmy najpierw definicje API wymagane do obsługi funkcjonalności repozytoriów:
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 });Gdy to będzie gotowe, wprowadźmy funkcję repozytorium składającą się z wyszukiwania/siatki/paginacji:
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;Interakcja z API Repositories jest bardziej złożona niż to, z czym mieliśmy do czynienia do tej pory, więc zdefiniujmy niestandardowe hooki, które zapewnią nam możliwość:
- Uzyskaj argumenty dla wywołań API.
- Pobierz bieżący wynik API, jak jest przechowywany w stanie.
- Pobierz dane, wywołując punkty końcowe interfejsu 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(); };Posiadanie tego poziomu separacji jako warstwy abstrakcji jest w tym przypadku ważne zarówno z perspektywy czytelności, jak i ze względu na wymagania RTK Query.
Jak mogłeś zauważyć, gdy wprowadziliśmy hook, który pobiera dane użytkownika za pomocą useQueryState , musieliśmy podać te same argumenty, które podaliśmy dla rzeczywistego wywołania API.
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; }; Ta wartość null, którą podajemy jako argument, istnieje niezależnie od tego, czy useQuery , czy useQueryState . Jest to wymagane, ponieważ RTK Query identyfikuje i buforuje część informacji na podstawie argumentów użytych do pobrania tych informacji.
Oznacza to, że w dowolnym momencie musimy mieć możliwość uzyskania argumentów wymaganych do wywołania API niezależnie od rzeczywistego wywołania API. W ten sposób możemy go użyć do pobrania stanu pamięci podręcznej danych API, kiedy tylko zajdzie taka potrzeba.
Jest jeszcze jedna rzecz, na którą musisz zwrócić uwagę w tym fragmencie kodu w naszej definicji API:
refetchOnMountOrArgChange: 60Czemu? Ponieważ jednym z ważnych punktów podczas korzystania z bibliotek takich jak RTK Query jest obsługa pamięci podręcznej klienta i unieważniania pamięci podręcznej. Jest to niezbędne, a także wymaga znacznego wysiłku, który może być trudny do zapewnienia w zależności od etapu rozwoju, w którym się znajdujesz.
Uważam, że RTK Query jest pod tym względem bardzo elastyczny. Użycie tej właściwości konfiguracyjnej pozwala nam:
- Całkowicie wyłącz buforowanie, co jest przydatne, gdy chcesz przeprowadzić migrację do RTK Query, unikając problemów z pamięcią podręczną jako pierwszy krok.
- Wprowadź buforowanie oparte na czasie, prosty mechanizm unieważniania, którego można użyć, gdy wiesz, że niektóre informacje mogą być buforowane przez X czasu.
Zobowiązuje
Zatwierdza różnicę i testuje różnicę
Ten krok dodaje więcej funkcji do strony repozytorium, dodając możliwość przeglądania zatwierdzeń dla każdego repozytorium, podziału tych zatwierdzeń na strony i filtrowania według gałęzi. Próbuje również naśladować funkcjonalność, którą można uzyskać na stronie GitHub.
Wprowadziliśmy dwa dodatkowe punkty końcowe do pobierania gałęzi i zatwierdzeń, a także niestandardowe haki dla tych punktów końcowych, zgodnie ze stylem, który ustaliliśmy podczas implementacji repozytoriów:
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]); };Zrobiwszy to, możemy teraz ulepszyć UX poprzez wstępne pobieranie danych commitów, gdy tylko ktoś najedzie na nazwę repozytorium:
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:
- Obsługa błędów
- Mutacje
- 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.
