Создание приложений React с помощью Redux Toolkit и RTK Query
Опубликовано: 2022-03-11Вы когда-нибудь хотели использовать Redux с такими функциями, как React Query? Теперь вы можете это сделать, используя Redux Toolkit и его последнее дополнение: RTK Query.
RTK Query — это расширенный инструмент для извлечения данных и кэширования на стороне клиента. Его функциональность аналогична React Query, но его преимущество заключается в прямой интеграции с Redux. Для взаимодействия с API разработчики обычно используют модули асинхронного промежуточного программного обеспечения, такие как Thunk, при работе с Redux. Такой подход ограничивает гибкость; таким образом, у разработчиков React теперь есть официальная альтернатива от команды Redux, которая покрывает все расширенные потребности современного взаимодействия клиент/сервер.
В этой статье показано, как RTK Query можно использовать в реальных сценариях, и каждый шаг содержит ссылку на коммит различий, чтобы выделить дополнительные функции. В конце появится ссылка на полную кодовую базу.
Шаблон и конфигурация
Разница инициализации проекта
Во-первых, нам нужно создать проект. Это делается с помощью шаблона Create React App (CRA) для использования с TypeScript и Redux:
npx create-react-app . --template redux-typescriptУ него есть несколько зависимостей, которые нам потребуются по пути, наиболее заметными из которых являются:
- Инструментарий Redux и запрос RTK
- Пользовательский интерфейс материала
- Лодаш
- Формик
- Реактивный маршрутизатор
Он также включает возможность предоставления пользовательской конфигурации для веб-пакета. Обычно CRA не поддерживает такие способности, если вы не выбросите их.
Инициализация
Гораздо более безопасный путь, чем извлечение, — это использовать что-то, что может изменить конфигурацию, особенно если эти изменения невелики. Этот шаблон использует react-app-rewired и custom-cra для выполнения этой функциональности, чтобы ввести пользовательскую конфигурацию 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 };Это делает работу разработчика удобнее, позволяя импортировать. Например:
import { omit } from 'lodash'; import { Box } from '@material-ui/core';Такой импорт обычно приводит к увеличению размера пакета, но с настроенной нами функцией перезаписи они будут работать следующим образом:
import omit from 'lodash/omit'; import Box from '@material-ui/core/Box';Конфигурация
Разница настроек Redux
Поскольку все приложение основано на Redux, после инициализации нам нужно будет настроить конфигурацию магазина:
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;Помимо стандартной конфигурации магазина, мы добавим конфигурацию для действия глобального сброса состояния, которое пригодится в реальных приложениях, как для самих приложений, так и для тестирования:
import { createAction } from '@reduxjs/toolkit'; export const RESET_STATE_ACTION_TYPE = 'resetState'; export const resetStateAction = createAction( RESET_STATE_ACTION_TYPE, () => { return { payload: null }; } );Далее мы добавим собственное промежуточное ПО для обработки ответов 401, просто очистив хранилище:
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); };Все идет нормально. Мы создали шаблон и настроили Redux. Теперь давайте добавим немного функциональности.
Аутентификация
Получение различий маркеров доступа
Для простоты аутентификация разбита на три этапа:
- Добавление определений API для получения токена доступа
- Добавление компонентов для обработки потока веб-аутентификации GitHub
- Завершение аутентификации путем предоставления служебных компонентов для предоставления пользователю всего приложения.
На этом шаге мы добавляем возможность получить токен доступа.
Идеология RTK Query требует, чтобы все определения API отображались в одном месте, что удобно при работе с приложениями корпоративного уровня с несколькими конечными точками. В корпоративном приложении намного проще созерцать интегрированный API, а также клиентское кеширование, когда все в одном месте.
RTK Query содержит инструменты для автоматического создания определений API с использованием стандартов OpenAPI или GraphQL. Эти инструменты все еще находятся в зачаточном состоянии, но они активно развиваются. Кроме того, эта библиотека предназначена для обеспечения отличного опыта разработчиков с TypeScript, который все чаще становится выбором для корпоративных приложений из-за его способности улучшать ремонтопригодность.
В нашем случае определения будут находиться в папке API. Пока нам требуется только это:
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 } }); }, }), }), });Аутентификация GitHub осуществляется через сервер аутентификации с открытым исходным кодом, который размещается отдельно на Heroku из-за требований API GitHub.
Сервер аутентификации
Хотя это и не требуется для этого примера проекта, читатели, желающие разместить собственную копию сервера аутентификации, должны будут:
- Создайте приложение OAuth в GitHub, чтобы сгенерировать собственный идентификатор клиента и секрет.
- Предоставьте данные GitHub серверу аутентификации через переменные среды
GITHUB_CLIENT_IDиGITHUB_SECRET. - Замените значение
baseUrlконечной точки аутентификации в приведенных выше определениях API. - На стороне React замените параметр
client_idв следующем примере кода.
Следующим шагом будет добавление компонентов, использующих этот API. Из-за требований потока веб-приложений GitHub нам понадобится компонент входа, отвечающий за перенаправление на 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; Как только GitHub перенаправит обратно в наше приложение, нам понадобится маршрут для обработки кода и получения на его основе 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]); Если вы когда-либо использовали React Query, механизм взаимодействия с API аналогичен RTK Query. Благодаря интеграции Redux это обеспечивает некоторые полезные функции, которые мы будем наблюдать при реализации дополнительных функций. Однако для access_token нам все еще нужно сохранить его в хранилище вручную, отправив действие:
dispatch(authSlice.actions.updateAccessToken(accessToken));Мы делаем это для возможности сохранения токена между перезагрузками страницы. Как для постоянства, так и для возможности отправки действия нам необходимо определить конфигурацию хранилища для нашей функции аутентификации.
Согласно соглашению, Redux Toolkit называет их слайсами:
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);Есть еще одно требование для того, чтобы предыдущий код функционировал. Каждый API должен предоставляться как редьюсер для конфигурации магазина, и каждый API поставляется со своим промежуточным программным обеспечением, которое необходимо включить:
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; Вот и все! Теперь наше приложение получает access_token , и мы готовы добавить к нему дополнительные функции аутентификации.
Завершение аутентификации
Завершение аутентификации Diff
Следующий список функций аутентификации включает:
- Возможность получить пользователя из GitHub API и предоставить его для остальной части приложения.
- Утилита для создания маршрутов, доступных только при аутентификации или при просмотре в качестве гостя.
Чтобы добавить возможность получения пользователя, нам понадобится некоторый шаблон API. В отличие от API аутентификации, GitHub API потребуется возможность извлекать токен доступа из нашего хранилища Redux и применять его к каждому запросу в качестве заголовка авторизации.
В RTK Query это достигается путем создания пользовательского базового запроса:
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();Здесь я использую axios, но можно использовать и другие клиенты.
Следующим шагом является определение API для получения информации о пользователе из 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'); }, }), }), }); Здесь мы используем наш собственный базовый запрос, что означает, что каждый запрос в области userApi будет включать заголовок авторизации. Давайте настроим конфигурацию основного магазина, чтобы API был доступен:
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;Затем нам нужно вызвать этот API перед визуализацией нашего приложения. Для простоты давайте сделаем это так, как работает функция разрешения для маршрутов Angular, чтобы ничего не отображалось, пока мы не получим информацию о пользователе.
Отсутствие пользователя также можно обрабатывать более детально, заранее предоставив некоторый пользовательский интерфейс, чтобы пользователь быстрее получил первый осмысленный рендеринг. Это требует больше размышлений и работы, и это определенно должно быть учтено в готовом к работе приложении.
Для этого нам нужно определить компонент промежуточного программного обеспечения:
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;Что это делает, просто. Он взаимодействует с GitHub API для получения информации о пользователе и не отображает дочерние элементы до того, как ответ будет доступен. Теперь, если мы обернем функциональность приложения этим компонентом, мы знаем, что информация о пользователе будет разрешена до того, как отобразится что-либо еще:
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; Перейдем к самой гладкой части. Теперь у нас есть возможность получать информацию о пользователе в любом месте приложения, хотя мы не сохраняли эту информацию вручную в магазине, как мы это делали с access_token .

Как? Создав для него простой пользовательский React Hook:
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 предоставляет параметр useQueryState для каждой конечной точки, что дает нам возможность получить текущее состояние для этой конечной точки.
Почему это так важно и полезно? Потому что нам не нужно писать много накладных расходов для управления кодом. В качестве бонуса мы получаем разделение между API/клиентскими данными в Redux из коробки.
Использование RTK Query позволяет избежать хлопот. Сочетая выборку данных с управлением состоянием, RTK Query устраняет пробел, который в противном случае был бы, даже если бы мы использовали React Query. (При использовании React Query к полученным данным должны обращаться несвязанные компоненты на уровне пользовательского интерфейса.)
В качестве последнего шага мы определяем стандартный пользовательский компонент маршрута, который использует этот хук, чтобы определить, должен ли маршрут отображаться или нет:
import React, { FC } from 'react'; import { Redirect, Route, RouteProps } from 'react-router'; import { useAuthUser } from '../../hooks/useAuthUser'; export type AuthenticatedRouteProps = { onlyPublic?: boolean; } & RouteProps; const AuthenticatedRoute: FC<AuthenticatedRouteProps> = ({ children, onlyPublic = false, ...routeProps }) => { const user = useAuthUser(); return ( <Route {...routeProps} render={({ location }) => { if (onlyPublic) { return !user ? ( children ) : ( <Redirect to={{ pathname: '/', state: { from: location } }} /> ); } return user ? ( children ) : ( <Redirect to={{ pathname: '/login', state: { from: location } }} /> ); }} /> ); }; export default AuthenticatedRoute;Тесты аутентификации Diff
В RTK Query нет ничего особенного, когда речь идет о написании тестов для приложений React. Лично я поддерживаю подход Кента К. Доддса к тестированию и стиль тестирования, который фокусируется на пользовательском опыте и взаимодействии с пользователем. При использовании RTK Query ничего особо не меняется.
При этом каждый шаг по-прежнему будет включать свои собственные тесты, чтобы продемонстрировать, что приложение, написанное с помощью RTK Query, отлично тестируется.
Примечание. В этом примере показано мое мнение о том, как эти тесты должны быть написаны в отношении того, что тестировать, над чем имитировать и насколько повторно использовать код.
Хранилища запросов RTK
Чтобы продемонстрировать RTK Query, мы представим некоторые дополнительные функции приложения, чтобы увидеть, как оно работает в определенных сценариях и как его можно использовать.
Репозитории Diff и тесты Diff
Первое, что мы сделаем, это представим функцию для репозиториев. Эта функция попытается имитировать функциональность вкладки «Репозитории», которую вы можете использовать в GitHub. Он будет посещать ваш профиль и иметь возможность искать репозитории и сортировать их по определенным критериям. На этом этапе вносится много изменений в файлы. Я призываю вас копаться в тех частях, которые вас интересуют.
Давайте сначала добавим определения API, необходимые для покрытия функциональности репозиториев:
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 });Как только это будет готово, давайте представим функцию репозитория, состоящую из поиска/сетки/пагинации:
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;Взаимодействие с Repositories API сложнее, чем то, с чем мы сталкивались до сих пор, поэтому давайте определим пользовательские хуки, которые предоставят нам возможность:
- Получить аргументы для вызовов API.
- Получите текущий результат API, сохраненный в состоянии.
- Извлекайте данные, вызывая конечные точки 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(); };Наличие такого уровня разделения в качестве уровня абстракции важно в данном случае как с точки зрения удобочитаемости, так и из-за требований RTK Query.
Как вы, возможно, заметили, когда мы представили хук, извлекающий пользовательские данные с помощью useQueryState , мы должны были предоставить те же аргументы, что и для фактического вызова 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; }; Этот нуль, который мы предоставляем в качестве аргумента, существует независимо от того, вызываем ли мы useQuery или useQueryState . Это необходимо, потому что RTK Query идентифицирует и кэширует часть информации с помощью аргументов, которые использовались для извлечения этой информации в первую очередь.
Это означает, что мы должны иметь возможность получать аргументы, необходимые для вызова API, отдельно от фактического вызова API в любой момент времени. Таким образом, мы можем использовать его для получения кэшированного состояния данных API, когда нам это нужно.
Есть еще одна вещь, на которую вам нужно обратить внимание в этом фрагменте кода в нашем определении API:
refetchOnMountOrArgChange: 60Почему? Потому что одним из важных моментов при использовании таких библиотек, как RTK Query, является обработка кеша клиента и инвалидация кеша. Это жизненно важно, а также требует значительных усилий, которые может быть трудно обеспечить в зависимости от фазы разработки, на которой вы находитесь.
Я нашел RTK Query очень гибким в этом отношении. Использование этого свойства конфигурации позволяет нам:
- Полностью отключите кэширование, что удобно, если вы хотите перейти на RTK Query, избегая проблем с кэшем в качестве начального шага.
- Внедрите кэширование на основе времени, простой механизм аннулирования, который можно использовать, когда известно, что некоторая информация может кэшироваться в течение X времени.
Коммиты
Фиксирует Diff и тестирует Diff
Этот шаг расширяет функциональные возможности страницы репозитория, добавляя возможность просмотра коммитов для каждого репозитория, разбиения этих коммитов на страницы и фильтрации по ветвям. Он также пытается имитировать функциональность, которую вы получите на странице GitHub.
Мы ввели еще две конечные точки для получения веток и коммитов, а также пользовательские хуки для этих конечных точек, следуя стилю, который мы установили при реализации репозиториев:
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]); };Сделав это, мы теперь можем улучшить UX, предварительно загружая данные коммитов, как только кто-то наводит курсор на имя репозитория:
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:
- Обработка ошибок
- Мутации
- Опрос
- 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.
