Redux Toolkit 및 RTK 쿼리로 React 앱 만들기

게시 됨: 2022-03-11

React Query가 제공하는 것과 같은 기능과 함께 Redux를 사용하고 싶었던 적이 있습니까? 이제 Redux Toolkit과 최신 추가 기능인 RTK 쿼리를 사용하여 할 수 있습니다.

RTK 쿼리는 고급 데이터 가져오기 및 클라이언트 측 캐싱 도구입니다. 기능은 React Query와 유사하지만 Redux와 직접 통합된다는 이점이 있습니다. API 상호 작용을 위해 개발자는 일반적으로 Redux로 작업할 때 Thunk와 같은 비동기 미들웨어 모듈을 사용합니다. 이러한 접근 방식은 유연성을 제한합니다. 따라서 React 개발자는 이제 오늘날의 클라이언트/서버 통신의 모든 고급 요구 사항을 다루는 Redux 팀의 공식 대안을 갖게 되었습니다.

이 문서에서는 RTK 쿼리를 실제 시나리오에서 사용하는 방법을 보여주며 각 단계에는 추가된 기능을 강조 표시하는 커밋 차이점에 대한 링크가 포함되어 있습니다. 전체 코드베이스에 대한 링크가 끝에 나타납니다.

상용구 및 구성

프로젝트 초기화 차이

먼저 프로젝트를 생성해야 합니다. 이것은 TypeScript 및 Redux와 함께 사용할 Create React App(CRA) 템플릿을 사용하여 수행됩니다.

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

그 과정에서 필요한 몇 가지 종속성이 있으며 가장 주목할만한 종속성은 다음과 같습니다.

  • Redux 툴킷 및 RTK 쿼리
  • 머티리얼 UI
  • 로다쉬
  • 포믹
  • 반응 라우터

또한 webpack에 대한 사용자 지정 구성을 제공하는 기능도 포함합니다. 일반적으로 CRA는 당신이 배출하지 않는 한 그러한 능력을 지원하지 않습니다.

초기화

꺼내기보다 훨씬 안전한 경로는 구성을 수정할 수 있는 항목을 사용하는 것입니다. 특히 이러한 수정 사항이 작은 경우에는 더욱 그렇습니다. 이 상용구는 react-app-rewired 및 customize-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 쿼리 이데올로기는 모든 API 정의가 한 곳에 나타나도록 지시하므로 여러 끝점이 있는 엔터프라이즈 수준 응용 프로그램을 처리할 때 편리합니다. 엔터프라이즈 애플리케이션에서는 모든 것이 한 곳에 있을 때 클라이언트 캐싱뿐만 아니라 통합 API를 고려하는 것이 훨씬 쉽습니다.

RTK 쿼리는 OpenAPI 표준 또는 GraphQL을 사용하여 API 정의를 자동 생성하기 위한 도구를 제공합니다. 이러한 도구는 아직 초기 단계이지만 활발히 개발되고 있습니다. 또한 이 라이브러리는 TypeScript를 사용하여 우수한 개발자 경험을 제공하도록 설계되었습니다. 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 인증은 GitHub API의 요구 사항으로 인해 Heroku에서 별도로 호스팅되는 오픈 소스 인증 서버를 통해 제공됩니다.

인증 서버

이 예제 프로젝트에는 필요하지 않지만 인증 서버의 자체 사본을 호스팅하려는 독자는 다음을 수행해야 합니다.

  1. GitHub에서 OAuth 앱을 만들어 고유한 클라이언트 ID와 비밀을 생성합니다.
  2. GITHUB_CLIENT_IDGITHUB_SECRET 환경 변수를 통해 인증 서버에 GitHub 세부 정보를 제공합니다.
  3. 위 API 정의에서 인증 엔드포인트 baseUrl 값을 교체하십시오.
  4. 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 을 검색하고 있으며 그 위에 더 많은 인증 기능을 추가할 준비가 되었습니다.

인증 완료

인증 차이 완료

인증을 위한 다음 기능 목록에는 다음이 포함됩니다.

  • GitHub API에서 사용자를 검색하고 앱의 나머지 부분에 제공하는 기능.
  • 인증된 경우 또는 게스트로 검색할 때만 액세스할 수 있는 경로를 갖는 유틸리티입니다.

사용자를 검색하는 기능을 추가하려면 API 상용구가 필요합니다. 인증 API와 달리 GitHub API는 Redux 저장소에서 액세스 토큰을 검색하고 모든 요청에 ​​Authorization 헤더로 적용하는 기능이 필요합니다.

사용자 지정 기본 쿼리를 생성하여 얻은 RTK 쿼리에서:

 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를 사용하고 있지만 다른 클라이언트도 사용할 수 있습니다.

다음 단계는 GitHub에서 사용자 정보를 검색하기 위한 API를 정의하는 것입니다.

 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 범위의 모든 요청에는 Authorization 헤더가 포함됩니다. 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 경로에 대한 확인 기능이 작동하는 방식과 유사한 방식으로 수행해 보겠습니다.

사용자가 없는 경우에도 사용자가 먼저 의미 있는 렌더링을 더 빨리 수행할 수 있도록 일부 UI를 미리 제공하여 보다 세분화된 방식으로 처리할 수 있습니다. 이를 위해서는 더 많은 생각과 작업이 필요하며 프로덕션 준비 앱에서 확실히 해결되어야 합니다.

그렇게 하려면 미들웨어 구성 요소를 정의해야 합니다.

 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 쿼리는 모든 끝점에 대해 useQueryState 옵션을 제공하여 해당 끝점의 현재 상태를 검색할 수 있는 기능을 제공합니다.

이것이 왜 그렇게 중요하고 유용한가? 코드를 관리하기 위해 많은 오버헤드를 작성할 필요가 없기 때문입니다. 보너스로 Redux에서 API/클라이언트 데이터를 즉시 분리할 수 있습니다.

RTK 쿼리를 사용하면 번거로움을 피할 수 있습니다. 데이터 가져오기와 상태 관리를 결합하여 RTK 쿼리는 React Query를 사용하더라도 있을 수 있는 격차를 제거합니다. (React Query를 사용하면 가져온 데이터는 UI 레이어의 관련 없는 구성 요소에서 액세스해야 합니다.)

마지막 단계로 이 후크를 사용하여 경로를 렌더링해야 하는지 여부를 결정하는 표준 사용자 지정 경로 구성 요소를 정의합니다.

 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;

인증 테스트 차이

React 앱에 대한 테스트를 작성할 때 RTK 쿼리에 고유한 것은 없습니다. 개인적으로 나는 Kent C. Dodds의 테스트 접근 방식과 사용자 경험과 사용자 상호 작용에 중점을 둔 테스트 스타일을 선호합니다. RTK 쿼리를 사용할 때 큰 변화는 없습니다.

즉, 각 단계에는 RTK 쿼리로 작성된 앱이 완벽하게 테스트 가능함을 입증하는 자체 테스트가 포함됩니다.

참고: 이 예제는 테스트 대상, 조롱 대상 및 코드 재사용 가능성과 관련하여 이러한 테스트를 작성하는 방법에 대한 나의 견해를 보여줍니다.

RTK 쿼리 저장소

RTK 쿼리를 보여주기 위해 응용 프로그램에 몇 가지 추가 기능을 도입하여 특정 시나리오에서 성능과 사용 방법을 확인합니다.

리포지토리 차이 및 테스트 차이

가장 먼저 할 일은 리포지토리에 대한 기능을 도입하는 것입니다. 이 기능은 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 });

준비가 되면 Search/Grid/Pagination으로 구성된 Repository 기능을 소개하겠습니다.

 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 쿼리 요구 사항으로 인해 추상화 계층으로 이러한 수준의 분리가 중요합니다.

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

우리가 인수로 제공하는 null은 useQueryState 를 호출 useQuery 를 호출하든 상관없이 존재합니다. 이는 RTK 쿼리가 처음에 해당 정보를 검색하는 데 사용된 인수로 정보를 식별하고 캐시하기 때문에 필요합니다.

즉, 실제 API 호출과 별도로 API 호출에 필요한 인수를 언제든지 얻을 수 있어야 합니다. 그렇게 하면 필요할 때마다 API 데이터의 캐시된 상태를 검색하는 데 사용할 수 있습니다.

API 정의의 이 코드에서 주의해야 할 사항이 한 가지 더 있습니다.

 refetchOnMountOrArgChange: 60

왜요? RTK 쿼리와 같은 라이브러리를 사용할 때 중요한 점 중 하나는 클라이언트 캐시 및 캐시 무효화를 처리하는 것이기 때문입니다. 이것은 매우 중요하며 상당한 노력이 필요합니다. 이는 현재 진행 중인 개발 단계에 따라 제공하기 어려울 수 있습니다.

그런 점에서 RTK 쿼리가 매우 유연하다는 것을 알았습니다. 이 구성 속성을 사용하면 다음을 수행할 수 있습니다.

  • 캐싱을 모두 비활성화하면 RTK 쿼리로 마이그레이션하려는 경우에 편리하여 초기 단계에서 캐시 문제를 방지할 수 있습니다.
  • 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:

  • 오류 처리
  • 돌연변이
  • 투표
  • 낙관적 업데이트

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.