ReduxToolkitとRTKクエリを使用したReactアプリの作成
公開: 2022-03-11ReactQueryが提供するような機能でReduxを使いたいと思ったことはありませんか? これで、Redux Toolkitとその最新の追加機能であるRTKクエリを使用して、それを実行できます。
RTKクエリは、高度なデータフェッチおよびクライアント側のキャッシュツールです。 その機能はReactQueryに似ていますが、Reduxと直接統合できるという利点があります。 APIインタラクションの場合、開発者は通常、Reduxを使用するときにThunkなどの非同期ミドルウェアモジュールを使用します。 このようなアプローチは柔軟性を制限します。 したがって、React開発者は、今日のクライアント/サーバー通信のすべての高度なニーズをカバーするReduxチームからの公式の代替手段を手に入れることができます。
この記事では、RTKクエリを実際のシナリオで使用する方法を示します。各ステップには、追加された機能を強調するためのコミット差分へのリンクが含まれています。 完全なコードベースへのリンクが最後に表示されます。
ボイラープレートと構成
プロジェクト初期化の相違
まず、プロジェクトを作成する必要があります。 これは、TypeScriptおよびReduxで使用するCreate React App(CRA)テンプレートを使用して行われます。
npx create-react-app . --template redux-typescript途中で必要になるいくつかの依存関係がありますが、最も注目すべきものは次のとおりです。
- ReduxToolkitとRTKクエリ
- マテリアルUI
- Lodash
- Formik
- Reactルーター
また、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を構成しました。 それでは、いくつかの機能を追加しましょう。
認証
アクセストークンの差分を取得する
認証は、簡単にするために3つのステップに分けられます。
- アクセストークンを取得するためのAPI定義の追加
- GitHubWeb認証フローを処理するためのコンポーネントの追加
- アプリ全体にユーザーを提供するためのユーティリティコンポーネントを提供することにより、認証を完了します
このステップでは、アクセストークンを取得する機能を追加します。
RTKクエリのイデオロギーでは、すべてのAPI定義が1つの場所に表示されるため、複数のエンドポイントを持つエンタープライズレベルのアプリケーションを処理する場合に便利です。 エンタープライズアプリケーションでは、すべてが1つの場所にある場合、統合APIとクライアントキャッシングを検討する方がはるかに簡単です。
RTKクエリは、OpenAPI標準またはGraphQLを使用してAPI定義を自動生成するためのツールを備えています。 これらのツールはまだ初期段階ですが、積極的に開発されています。 さらに、このライブラリは、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で個別にホストされるオープンソース認証サーバーを介して提供されます。
認証サーバー
このサンプルプロジェクトでは必須ではありませんが、認証サーバーの独自のコピーをホストすることを希望する読者は、次のことを行う必要があります。
- GitHubでOAuthアプリを作成して、独自のクライアントIDとシークレットを生成します。
- 環境変数
GITHUB_CLIENT_IDおよびGITHUB_SECRETを介して、GitHubの詳細を認証サーバーに提供します。 - 上記のAPI定義の認証エンドポイント
baseUrl値を置き換えます。 - React側で、次のコードサンプルの
client_idパラメーターを置き換えます。
次のステップは、このAPIを使用するコンポーネントを追加することです。 GitHub Webアプリケーションフローの要件により、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と対話するためのメカニズムはRTKQueryの場合と同様です。 これは、追加機能を実装するときに観察するRedux統合のおかげで、いくつかの優れた機能を提供します。 ただし、 access_tokenの場合は、アクションをディスパッチして手動でストアに保存する必要があります。
dispatch(authSlice.actions.updateAccessToken(accessToken));これは、ページのリロード間でトークンを永続化する機能のために行います。 永続性とアクションをディスパッチする機能の両方について、認証機能のストア構成を定義する必要があります。
慣例により、ReduxToolkitはこれらをスライスと呼びます。
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);上記のコードが機能するためのもう1つの要件があります。 各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フックを作成することで:
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クエリを使用した場合でも存在するギャップを排除します。 (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 });準備ができたら、検索/グリッド/ページネーションで構成されるリポジトリ機能を紹介しましょう。
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は、 useQueryまたはuseQueryStateのどちらを呼び出す場合でも存在します。 RTKクエリは、最初にその情報を取得するために使用された引数によって情報を識別してキャッシュするため、これが必要です。
つまり、API呼び出しに必要な引数を、実際のAPI呼び出しとは別にいつでも取得できる必要があります。 そうすれば、必要なときにいつでもAPIデータのキャッシュ状態を取得するために使用できます。
API定義のこのコードで注意する必要があるもう1つのことがあります。
refetchOnMountOrArgChange: 60なんで? RTKクエリなどのライブラリを使用する際の重要なポイントの1つは、クライアントキャッシュとキャッシュ無効化の処理であるためです。 これは非常に重要であり、かなりの労力を必要としますが、開発の段階によっては提供が難しい場合があります。
その点で、RTKクエリは非常に柔軟であることがわかりました。 この構成プロパティを使用すると、次のことが可能になります。
- キャッシュを完全に無効にします。これは、RTKクエリに移行する場合に便利であり、最初のステップとしてキャッシュの問題を回避します。
- 時間ベースのキャッシュを導入します。これは、一部の情報をX時間キャッシュできることがわかっている場合に使用する単純な無効化メカニズムです。
コミット
差分をコミットし、差分をテストします
この手順では、各リポジトリのコミットを表示し、それらのコミットをページ分割し、ブランチでフィルタリングする機能を追加することで、リポジトリページに機能を追加します。 また、GitHubページで得られる機能を模倣しようとします。
リポジトリの実装中に確立したスタイルに従って、ブランチとコミットを取得するための2つのエンドポイントと、これらのエンドポイントのカスタムフックを導入しました。
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.
