Membuat Aplikasi React Dengan Redux Toolkit dan RTK Query

Diterbitkan: 2022-03-11

Pernahkah Anda ingin menggunakan Redux dengan fitur-fitur seperti yang disediakan oleh React Query? Sekarang Anda bisa, dengan menggunakan Redux Toolkit dan tambahan terbarunya: RTK Query.

RTK Query adalah alat pengambilan data dan caching sisi klien tingkat lanjut. Fungsionalitasnya mirip dengan React Query tetapi memiliki keuntungan terintegrasi langsung dengan Redux. Untuk interaksi API, pengembang biasanya menggunakan modul middleware async seperti Thunk saat bekerja dengan Redux. Pendekatan seperti itu membatasi fleksibilitas; jadi pengembang Bereaksi sekarang memiliki alternatif resmi dari tim Redux yang mencakup semua kebutuhan tingkat lanjut dari komunikasi klien/server saat ini.

Artikel ini menunjukkan bagaimana Kueri RTK dapat digunakan dalam skenario dunia nyata, dan setiap langkah menyertakan tautan ke perbedaan komit untuk menyoroti fungsionalitas tambahan. Tautan ke basis kode lengkap muncul di bagian akhir.

Boilerplate dan Konfigurasi

Perbedaan Inisialisasi Proyek

Pertama, kita perlu membuat proyek. Ini dilakukan dengan menggunakan template Create React App (CRA) untuk digunakan dengan TypeScript dan Redux:

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

Ini memiliki beberapa dependensi yang akan kita perlukan di sepanjang jalan, yang paling menonjol adalah:

  • Toolkit Redux dan Kueri RTK
  • UI bahan
  • Lodash
  • Formik
  • Bereaksi Router

Ini juga mencakup kemampuan untuk menyediakan konfigurasi khusus untuk webpack. Biasanya, CRA tidak mendukung kemampuan seperti itu kecuali Anda mengeluarkan.

inisialisasi

Rute yang jauh lebih aman daripada eject adalah dengan menggunakan sesuatu yang dapat memodifikasi konfigurasi, terutama jika modifikasi tersebut kecil. Boilerplate ini menggunakan react-app-rewired dan customize-cra untuk mencapai fungsionalitas tersebut guna memperkenalkan konfigurasi babel khusus:

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

Ini membuat pengalaman pengembang lebih baik dengan mengizinkan impor. Sebagai contoh:

 import { omit } from 'lodash'; import { Box } from '@material-ui/core';

Impor semacam itu biasanya menghasilkan peningkatan ukuran bundel, tetapi dengan fungsi penulisan ulang yang kami konfigurasikan, ini akan berfungsi seperti ini:

 import omit from 'lodash/omit'; import Box from '@material-ui/core/Box';

Konfigurasi

Perbedaan Pengaturan Redux

Karena seluruh aplikasi didasarkan pada Redux, setelah inisialisasi kita perlu mengatur konfigurasi toko:

 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;

Terlepas dari konfigurasi toko standar, kami akan menambahkan konfigurasi untuk tindakan status reset global yang berguna di aplikasi dunia nyata, baik untuk aplikasi itu sendiri maupun untuk pengujian:

 import { createAction } from '@reduxjs/toolkit'; export const RESET_STATE_ACTION_TYPE = 'resetState'; export const resetStateAction = createAction( RESET_STATE_ACTION_TYPE, () => { return { payload: null }; } );

Selanjutnya, kami akan menambahkan middleware khusus untuk menangani 401 tanggapan hanya dengan membersihkan toko:

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

Sejauh ini bagus. Kami telah membuat boilerplate dan mengonfigurasi Redux. Sekarang mari tambahkan beberapa fungsionalitas.

Autentikasi

Mengambil Perbedaan Token Akses

Otentikasi dipecah menjadi tiga langkah untuk kesederhanaan:

  • Menambahkan definisi API untuk mengambil token akses
  • Menambahkan komponen untuk menangani alur autentikasi web GitHub
  • Menyelesaikan otentikasi dengan menyediakan komponen utilitas untuk menyediakan pengguna ke seluruh aplikasi

Pada langkah ini, kami menambahkan kemampuan untuk mengambil token akses.

Ideologi RTK Query menyatakan bahwa semua definisi API muncul di satu tempat, yang berguna saat menangani aplikasi tingkat perusahaan dengan beberapa titik akhir. Dalam aplikasi perusahaan, jauh lebih mudah untuk merenungkan API terintegrasi, serta caching klien, ketika semuanya ada di satu tempat.

RTK Query menampilkan alat untuk membuat definisi API secara otomatis menggunakan standar OpenAPI atau GraphQL. Alat-alat ini masih dalam masa pertumbuhan, tetapi sedang dikembangkan secara aktif. Selain itu, perpustakaan ini dirancang untuk memberikan pengalaman pengembang yang sangat baik dengan TypeScript, yang semakin menjadi pilihan untuk aplikasi perusahaan karena kemampuannya untuk meningkatkan pemeliharaan.

Dalam kasus kami, definisi akan berada di bawah folder API. Untuk saat ini kami hanya membutuhkan ini:

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

Otentikasi GitHub disediakan melalui server otentikasi sumber terbuka, yang dihosting secara terpisah di Heroku karena persyaratan API GitHub.

Server Otentikasi

Meskipun tidak diperlukan untuk proyek contoh ini, pembaca yang ingin meng-host salinan server otentikasi mereka sendiri perlu:

  1. Buat aplikasi OAuth di GitHub untuk membuat ID klien dan rahasia mereka sendiri.
  2. Berikan detail GitHub ke server otentikasi melalui variabel lingkungan GITHUB_CLIENT_ID dan GITHUB_SECRET .
  3. Ganti nilai endpoint baseUrl otentikasi dalam definisi API di atas.
  4. Di sisi React, ganti parameter client_id dalam contoh kode berikutnya.

Langkah selanjutnya adalah menambahkan komponen yang menggunakan API ini. Karena persyaratan aliran aplikasi web GitHub, kita memerlukan komponen login yang bertanggung jawab untuk mengarahkan ulang ke 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;

Setelah GitHub mengarahkan kembali ke aplikasi kita, kita akan memerlukan rute untuk menangani kode dan mengambil access_token berdasarkan kode tersebut:

 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]);

Jika Anda pernah menggunakan React Query, mekanisme untuk berinteraksi dengan API serupa dengan RTK Query. Ini memberikan beberapa fitur yang rapi berkat integrasi Redux yang akan kami amati saat kami menerapkan fitur tambahan. Untuk access_token , kita masih perlu menyimpannya di toko secara manual dengan mengirimkan tindakan:

 dispatch(authSlice.actions.updateAccessToken(accessToken));

Kami melakukan ini untuk kemampuan mempertahankan token di antara pemuatan ulang halaman. Baik untuk ketekunan dan kemampuan untuk mengirimkan tindakan, kita perlu mendefinisikan konfigurasi penyimpanan untuk fitur otentikasi kita.

Per konvensi, Redux Toolkit menyebutnya sebagai irisan:

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

Ada satu persyaratan lagi agar kode sebelumnya berfungsi. Setiap API harus disediakan sebagai peredam untuk konfigurasi toko, dan setiap API dilengkapi dengan middlewarenya sendiri, yang harus Anda sertakan:

 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;

Itu dia! Sekarang aplikasi kami mengambil access_token dan kami siap untuk menambahkan lebih banyak fitur otentikasi di atasnya.

Menyelesaikan Otentikasi

Menyelesaikan Perbedaan Otentikasi

Daftar fitur selanjutnya untuk otentikasi meliputi:

  • Kemampuan untuk mengambil pengguna dari GitHub API dan menyediakannya untuk aplikasi lainnya.
  • Utilitas untuk memiliki rute yang hanya dapat diakses saat diautentikasi atau saat menjelajah sebagai tamu.

Untuk menambahkan kemampuan untuk mengambil pengguna, kita memerlukan beberapa boilerplate API. Tidak seperti API otentikasi, API GitHub akan membutuhkan kemampuan untuk mengambil token akses dari toko Redux kami dan menerapkannya ke setiap permintaan sebagai header Otorisasi.

Dalam Kueri RTK yang dicapai dengan membuat kueri basis kustom:

 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();

Saya menggunakan aksioma di sini, tetapi klien lain juga dapat digunakan.

Langkah selanjutnya adalah mendefinisikan API untuk mengambil informasi pengguna dari 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'); }, }), }), });

Kami menggunakan kueri basis kustom kami di sini, artinya setiap permintaan dalam cakupan userApi akan menyertakan header Otorisasi. Mari kita ubah konfigurasi toko utama sehingga API tersedia:

 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;

Selanjutnya, kita perlu memanggil API ini sebelum aplikasi kita dirender. Untuk mempermudah, mari kita lakukan dengan cara yang menyerupai cara kerja fungsi penyelesaian untuk rute Angular sehingga tidak ada yang dirender sampai kita mendapatkan informasi pengguna.

Ketiadaan pengguna juga dapat ditangani dengan cara yang lebih terperinci, dengan menyediakan beberapa UI terlebih dahulu sehingga pengguna memiliki render bermakna pertama dengan lebih cepat. Ini membutuhkan lebih banyak pemikiran dan kerja, dan pasti harus ditangani dalam aplikasi yang siap produksi.

Untuk melakukan itu, kita perlu mendefinisikan komponen middleware:

 import React, { FC } from 'react'; import { userApi } from '../../../../api/github/user/api'; import FullscreenProgress from '../../../../shared/components/FullscreenProgress/FullscreenProgress'; import { RootState, useTypedSelector } from '../../../../shared/redux/store'; import { useAuthUser } from '../../hooks/useAuthUser'; const UserMiddleware: FC = ({ children }) => { const accessToken = useTypedSelector( (state: RootState) => state.authSlice.accessToken ); const user = useAuthUser(); userApi.endpoints.getUser.useQuery(null, { skip: !accessToken }); if (!user && accessToken) { return ( <FullscreenProgress/> ); } return children as React.ReactElement; }; export default UserMiddleware;

Apa yang dilakukan ini sangat mudah. Ini berinteraksi dengan GitHub API untuk mendapatkan informasi pengguna dan tidak merender anak sebelum respons tersedia. Sekarang jika kita membungkus fungsionalitas aplikasi dengan komponen ini, kita tahu bahwa informasi pengguna akan diselesaikan sebelum hal lain dirender:

 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;

Mari kita beralih ke bagian yang paling halus. Kami sekarang memiliki kemampuan untuk mendapatkan informasi pengguna di mana saja di aplikasi, meskipun kami tidak menyimpan informasi pengguna itu di dalam toko secara manual seperti yang kami lakukan dengan access_token .

Bagaimana? Dengan membuat React Hook kustom sederhana untuknya:

 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 menyediakan opsi useQueryState untuk setiap titik akhir, yang memberi kita kemampuan untuk mengambil status saat ini untuk titik akhir tersebut.

Mengapa ini sangat penting dan berguna? Karena kita tidak perlu menulis banyak overhead untuk mengelola kode. Sebagai bonus, kami mendapatkan pemisahan antara API/data klien di Redux di luar kotak.

Menggunakan RTK Query menghindari kerumitan. Dengan menggabungkan pengambilan data dengan manajemen status, RTK Query menghilangkan celah yang seharusnya ada bahkan jika kita menggunakan React Query. (Dengan React Query, data yang diambil harus diakses oleh komponen yang tidak terkait pada lapisan UI.)

Sebagai langkah terakhir, kami mendefinisikan komponen rute kustom standar yang menggunakan kait ini untuk menentukan apakah rute harus dirender atau tidak:

 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;

Uji Otentikasi Diff

Tidak ada yang khusus untuk RTK Query dalam hal menulis tes untuk aplikasi React. Secara pribadi, saya mendukung pendekatan Kent C. Dodds untuk pengujian dan gaya pengujian yang berfokus pada pengalaman pengguna dan interaksi pengguna. Tidak banyak perubahan saat menggunakan RTK Query.

Meskipun demikian, setiap langkah masih akan menyertakan pengujiannya sendiri untuk menunjukkan bahwa aplikasi yang ditulis dengan RTK Query dapat diuji dengan sempurna.

Catatan: Contoh ini menunjukkan pendapat saya tentang bagaimana tes tersebut harus ditulis sehubungan dengan apa yang harus diuji, apa yang harus diejek, dan seberapa banyak penggunaan kembali kode yang harus diperkenalkan.

Repositori Kueri RTK

Untuk menampilkan RTK Query, kami akan memperkenalkan beberapa fitur tambahan ke aplikasi untuk melihat bagaimana kinerjanya dalam skenario tertentu dan bagaimana itu dapat digunakan.

Perbedaan Repositori dan Perbedaan Pengujian

Hal pertama yang akan kita lakukan adalah memperkenalkan fitur untuk repositori. Fitur ini akan mencoba meniru fungsionalitas tab Repositori yang dapat Anda alami di GitHub. Ini akan mengunjungi profil Anda dan memiliki kemampuan untuk mencari repositori dan mengurutkannya berdasarkan kriteria tertentu. Ada banyak perubahan file yang diperkenalkan pada langkah ini. Saya mendorong Anda untuk menggali bagian-bagian yang Anda minati.

Mari tambahkan definisi API yang diperlukan untuk mencakup fungsionalitas repositori terlebih dahulu:

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

Setelah siap, mari kita perkenalkan fitur Repositori yang terdiri dari Search/Grid/Pagination:

 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;

Interaksi dengan Repositories API lebih kompleks daripada yang kita temui sejauh ini, jadi mari kita definisikan custom hook yang akan memberi kita kemampuan untuk:

  • Dapatkan argumen untuk panggilan API.
  • Dapatkan hasil API saat ini seperti yang disimpan dalam status.
  • Ambil data dengan memanggil titik akhir 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(); };

Memiliki tingkat pemisahan ini sebagai lapisan abstraksi penting dalam hal ini baik dari perspektif keterbacaan dan karena persyaratan Kueri RTK.

Seperti yang mungkin Anda perhatikan ketika kami memperkenalkan sebuah kait yang mengambil data pengguna dengan menggunakan useQueryState , kami harus memberikan argumen yang sama yang kami berikan untuk panggilan API yang sebenarnya.

 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 yang kami berikan sebagai argumen ada apakah kami memanggil useQuery atau useQueryState . Itu diperlukan karena RTK Query mengidentifikasi dan menyimpan sepotong informasi dengan argumen yang digunakan untuk mengambil informasi itu di tempat pertama.

Ini berarti kita harus bisa mendapatkan argumen yang diperlukan untuk panggilan API secara terpisah dari panggilan API yang sebenarnya kapan saja. Dengan begitu, kita dapat menggunakannya untuk mengambil status cache data API kapan pun kita perlukan.

Ada satu hal lagi yang perlu Anda perhatikan dalam bagian kode ini dalam definisi API kami:

 refetchOnMountOrArgChange: 60

Mengapa? Karena salah satu poin penting saat menggunakan perpustakaan seperti RTK Query adalah menangani cache klien dan pembatalan cache. Ini sangat penting dan juga membutuhkan banyak usaha, yang mungkin sulit dilakukan tergantung pada fase pengembangan yang Anda jalani.

Saya menemukan RTK Query sangat fleksibel dalam hal itu. Menggunakan properti konfigurasi ini memungkinkan kita untuk:

  • Nonaktifkan caching sama sekali, yang berguna saat Anda ingin bermigrasi ke RTK Query, menghindari masalah cache sebagai langkah awal.
  • Perkenalkan caching berbasis waktu, mekanisme pembatalan sederhana untuk digunakan ketika Anda tahu bahwa beberapa informasi dapat di-cache untuk jumlah waktu X.

berkomitmen

Melakukan Diff dan Tes Diff

Langkah ini menambahkan lebih banyak fungsionalitas ke halaman repositori dengan menambahkan kemampuan untuk melihat komit untuk setiap repositori, membuat paginasi komit tersebut, dan memfilter menurut cabang. Itu juga mencoba meniru fungsionalitas yang Anda dapatkan di halaman GitHub.

Kami telah memperkenalkan dua titik akhir lagi untuk mendapatkan cabang dan komit, serta kait khusus untuk titik akhir ini, mengikuti gaya yang kami buat selama implementasi repositori:

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

Setelah melakukan ini, kami sekarang dapat meningkatkan UX dengan mengambil data komit segera setelah seseorang mengarahkan kursor ke nama repositori:

 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:

  • Penanganan Kesalahan
  • Mutasi
  • Jajak pendapat
  • 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.