Creación de aplicaciones React con Redux Toolkit y RTK Query

Publicado: 2022-03-11

¿Alguna vez ha querido usar Redux con funciones como las que ofrece React Query? Ahora puede, usando Redux Toolkit y su última incorporación: RTK Query.

RTK Query es una herramienta avanzada de captura de datos y almacenamiento en caché del lado del cliente. Su funcionalidad es similar a React Query pero tiene la ventaja de estar directamente integrado con Redux. Para la interacción de la API, los desarrolladores suelen utilizar módulos de middleware asíncronos como Thunk cuando trabajan con Redux. Tal enfoque limita la flexibilidad; por lo tanto, los desarrolladores de React ahora tienen una alternativa oficial del equipo de Redux que cubre todas las necesidades avanzadas de la comunicación cliente/servidor de hoy.

Este artículo demuestra cómo se puede usar RTK Query en escenarios del mundo real, y cada paso incluye un enlace a una diferencia de compromiso para resaltar la funcionalidad agregada. Al final aparece un enlace al código base completo.

Modelo y configuración

Diferencia de inicialización del proyecto

Primero, necesitamos crear un proyecto. Esto se hace usando la plantilla Create React App (CRA) para usar con TypeScript y Redux:

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

Tiene varias dependencias que iremos necesitando en el camino, siendo las más destacables:

  • Kit de herramientas Redux y consulta RTK
  • Interfaz de usuario de materiales
  • Lodash
  • Formik
  • Reaccionar enrutador

También incluye la capacidad de proporcionar una configuración personalizada para el paquete web. Normalmente, CRA no admite tales habilidades a menos que expulse.

Inicialización

Una ruta mucho más segura que expulsar es usar algo que pueda modificar la configuración, especialmente si esas modificaciones son pequeñas. Este modelo utiliza react-app-rewired y customize-cra para lograr esa funcionalidad para introducir una configuración de babel personalizada:

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

Esto mejora la experiencia del desarrollador al permitir las importaciones. Por ejemplo:

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

Dichas importaciones generalmente dan como resultado un tamaño de paquete aumentado, pero con la funcionalidad de reescritura que configuramos, funcionarán así:

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

Configuración

Diferencia de configuración de Redux

Dado que toda la aplicación se basa en Redux, después de la inicialización necesitaremos configurar la configuración de la tienda:

 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;

Además de la configuración estándar de la tienda, agregaremos la configuración para una acción de estado de restablecimiento global que resulta útil en las aplicaciones del mundo real, tanto para las aplicaciones en sí como para las pruebas:

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

A continuación, agregaremos un middleware personalizado para manejar las respuestas 401 simplemente limpiando la tienda:

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

Hasta aquí todo bien. Hemos creado el repetitivo y configurado Redux. Ahora agreguemos algunas funciones.

Autenticación

Recuperación de la diferencia del token de acceso

La autenticación se divide en tres pasos para simplificar:

  • Adición de definiciones de API para recuperar un token de acceso
  • Agregar componentes para manejar el flujo de autenticación web de GitHub
  • Finalización de la autenticación proporcionando componentes de utilidad para proporcionar al usuario toda la aplicación

En este paso, agregamos la capacidad de recuperar el token de acceso.

La ideología de RTK Query dicta que todas las definiciones de API aparecen en un solo lugar, lo cual es útil cuando se trata de aplicaciones de nivel empresarial con varios puntos finales. En una aplicación empresarial, es mucho más fácil contemplar la API integrada, así como el almacenamiento en caché del cliente, cuando todo está en un solo lugar.

RTK Query presenta herramientas para generar automáticamente definiciones de API utilizando estándares OpenAPI o GraphQL. Estas herramientas aún están en su infancia, pero se están desarrollando activamente. Además, esta biblioteca está diseñada para proporcionar una excelente experiencia de desarrollador con TypeScript, que se está convirtiendo cada vez más en la elección de las aplicaciones empresariales debido a su capacidad para mejorar la capacidad de mantenimiento.

En nuestro caso, las definiciones residirán en la carpeta API. Por ahora solo hemos requerido esto:

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

La autenticación de GitHub se proporciona a través de un servidor de autenticación de código abierto, que se aloja por separado en Heroku debido a los requisitos de la API de GitHub.

El servidor de autenticación

Si bien no es necesario para este proyecto de ejemplo, los lectores que deseen alojar su propia copia del servidor de autenticación deberán:

  1. Cree una aplicación OAuth en GitHub para generar su propio ID y secreto de cliente.
  2. Proporcione detalles de GitHub al servidor de autenticación a través de las variables de entorno GITHUB_CLIENT_ID y GITHUB_SECRET .
  3. Reemplace el valor baseUrl del extremo de autenticación en las definiciones de API anteriores.
  4. En el lado de React, reemplace el parámetro client_id en el siguiente ejemplo de código.

El siguiente paso es agregar componentes que usen esta API. Debido a los requisitos del flujo de la aplicación web de GitHub, necesitaremos un componente de inicio de sesión responsable de redirigir a 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;

Una vez que GitHub redirige a nuestra aplicación, necesitaremos una ruta para manejar el código y recuperar access_token en función de él:

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

Si alguna vez usó React Query, el mecanismo para interactuar con la API es similar para RTK Query. Esto proporciona algunas funciones interesantes gracias a la integración de Redux que observaremos a medida que implementemos funciones adicionales. Sin embargo, para access_token , todavía tenemos que guardarlo en la tienda manualmente mediante el envío de una acción:

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

Hacemos esto por la capacidad de conservar el token entre recargas de página. Tanto para la persistencia como para la capacidad de enviar la acción, necesitamos definir una configuración de almacenamiento para nuestra función de autenticación.

Por convención, Redux Toolkit se refiere a estos como segmentos:

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

Hay un requisito más para que funcione el código anterior. Cada API debe proporcionarse como un reductor para la configuración de la tienda, y cada API viene con su propio middleware, que debe incluir:

 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;

¡Eso es todo! Ahora nuestra aplicación está recuperando access_token y estamos listos para agregar más funciones de autenticación.

Completar la autenticación

Completando diferencia de autenticación

La siguiente lista de características para la autenticación incluye:

  • La capacidad de recuperar al usuario de la API de GitHub y proporcionarlo para el resto de la aplicación.
  • La utilidad de tener rutas a las que solo se puede acceder cuando se está autenticado o cuando se navega como invitado.

Para agregar la capacidad de recuperar al usuario, necesitaremos algunos modelos de API. A diferencia de la API de autenticación, la API de GitHub necesitará la capacidad de recuperar el token de acceso de nuestra tienda Redux y aplicarlo a cada solicitud como encabezado de autorización.

En RTK Query eso se logra creando una consulta base personalizada:

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

Estoy usando axios aquí, pero también se pueden usar otros clientes.

El siguiente paso es definir una API para recuperar información de usuario de 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'); }, }), }), });

Usamos nuestra consulta base personalizada aquí, lo que significa que cada solicitud en el ámbito de userApi incluirá un encabezado de Autorización. Modifiquemos la configuración de la tienda principal para que la API esté disponible:

 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;

A continuación, debemos llamar a esta API antes de que se procese nuestra aplicación. Para simplificar, hagámoslo de una manera que se asemeje a cómo funciona la funcionalidad de resolución para las rutas Angular para que no se represente nada hasta que obtengamos la información del usuario.

La ausencia del usuario también se puede manejar de una manera más granular, al proporcionar algo de interfaz de usuario de antemano para que el usuario tenga una primera representación significativa más rápidamente. Esto requiere más reflexión y trabajo, y definitivamente debe abordarse en una aplicación lista para producción.

Para hacer eso, necesitamos definir un componente de 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;

Lo que esto hace es sencillo. Interactúa con la API de GitHub para obtener información del usuario y no genera elementos secundarios antes de que la respuesta esté disponible. Ahora, si envolvemos la funcionalidad de la aplicación con este componente, sabemos que la información del usuario se resolverá antes de que se muestre cualquier otra cosa:

 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;

Pasemos a la parte más elegante. Ahora tenemos la capacidad de obtener información del usuario en cualquier parte de la aplicación, aunque no guardamos esa información del usuario en la tienda manualmente como lo hicimos con access_token .

¿Cómo? Al crear un React Hook personalizado simple para él:

 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 proporciona la opción useQueryState para cada punto final, lo que nos permite recuperar el estado actual de ese punto final.

¿Por qué es esto tan importante y útil? Porque no tenemos que escribir muchos gastos generales para administrar el código. Como beneficio adicional, obtenemos una separación entre la API y los datos del cliente en Redux listos para usar.

El uso de RTK Query evita la molestia. Al combinar la obtención de datos con la gestión del estado, RTK Query elimina la brecha que de otro modo existiría incluso si usáramos React Query. (Con React Query, los componentes no relacionados en la capa de la interfaz de usuario deben acceder a los datos obtenidos).

Como paso final, definimos un componente de ruta personalizado estándar que usa este enlace para determinar si una ruta debe representarse o no:

 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;

Diferencia de pruebas de autenticación

No hay nada intrínsecamente específico para RTK Query cuando se trata de escribir pruebas para aplicaciones React. Personalmente, estoy a favor del enfoque de prueba de Kent C. Dodds y un estilo de prueba que se centra en la experiencia del usuario y la interacción del usuario. Nada cambia mucho cuando se usa RTK Query.

Dicho esto, cada paso seguirá incluyendo sus propias pruebas para demostrar que una aplicación escrita con RTK Query es perfectamente comprobable.

Nota: El ejemplo muestra mi opinión sobre cómo se deben escribir esas pruebas con respecto a qué probar, qué simular y cuánta reutilización de código introducir.

Repositorios de consultas RTK

Para mostrar RTK Query, presentaremos algunas características adicionales a la aplicación para ver cómo se desempeña en ciertos escenarios y cómo se puede usar.

Repositorios Diff y Tests Diff

Lo primero que haremos será presentar una característica para los repositorios. Esta característica intentará imitar la funcionalidad de la pestaña Repositorios que puedes experimentar en GitHub. Visitará su perfil y tendrá la capacidad de buscar repositorios y ordenarlos según ciertos criterios. Hay muchos cambios de archivos introducidos en este paso. Te animo a profundizar en las partes que te interesan.

Agreguemos primero las definiciones de API requeridas para cubrir la funcionalidad de los repositorios:

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

Una vez que esté listo, introduzcamos una función de Repositorio que consiste en Búsqueda/Cuadrícula/Paginación:

 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;

La interacción con la API de repositorios es más compleja de lo que hemos encontrado hasta ahora, así que definamos ganchos personalizados que nos brindarán la capacidad de:

  • Obtenga argumentos para las llamadas a la API.
  • Obtenga el resultado de la API actual tal como está almacenado en el estado.
  • Obtenga datos llamando a los puntos finales de la 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(); };

Tener este nivel de separación como una capa de abstracción es importante en este caso tanto desde la perspectiva de la legibilidad como debido a los requisitos de consulta RTK.

Como habrá notado cuando introdujimos un enlace que recupera datos de usuario mediante useQueryState , tuvimos que proporcionar los mismos argumentos que proporcionamos para la llamada a la API real.

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

Ese nulo que proporcionamos como argumento está ahí, ya sea que llamemos a useQuery o useQueryState . Eso es necesario porque RTK Query identifica y almacena en caché una parte de la información por los argumentos que se usaron para recuperar esa información en primer lugar.

Esto significa que debemos poder obtener los argumentos necesarios para una llamada a la API por separado de la llamada a la API real en cualquier momento. De esa manera, podemos usarlo para recuperar el estado almacenado en caché de los datos de la API siempre que lo necesitemos.

Hay una cosa más a la que debe prestar atención en este fragmento de código en nuestra definición de API:

 refetchOnMountOrArgChange: 60

¿Por qué? Porque uno de los puntos importantes al usar bibliotecas como RTK Query es manejar la caché del cliente y la invalidación de la caché. Esto es vital y también requiere una gran cantidad de esfuerzo, que puede ser difícil de proporcionar dependiendo de la fase de desarrollo en la que se encuentre.

Descubrí que RTK Query es muy flexible en ese sentido. El uso de esta propiedad de configuración nos permite:

  • Deshabilite el almacenamiento en caché por completo, lo que resulta útil cuando desea migrar a RTK Query, evitando problemas de caché como paso inicial.
  • Introduzca el almacenamiento en caché basado en el tiempo, un mecanismo de invalidación simple para usar cuando sabe que alguna información se puede almacenar en caché durante X cantidad de tiempo.

confirma

Confirma Dif y prueba Dif

Este paso agrega más funcionalidad a la página del repositorio al agregar la capacidad de ver confirmaciones para cada repositorio, paginar esas confirmaciones y filtrar por rama. También intenta imitar la funcionalidad que obtendría en una página de GitHub.

Hemos introducido dos puntos finales más para obtener ramas y confirmaciones, así como enlaces personalizados para estos puntos finales, siguiendo el estilo que establecimos durante la implementación de los repositorios:

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

Una vez hecho esto, ahora podemos mejorar la UX precargando datos de confirmación tan pronto como alguien pase el mouse sobre el nombre del repositorio:

 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:

  • Manejo de errores
  • Mutaciones
  • Polling
  • Optimistic Updates

If you're intrigued by RTK Query's benefits, I encourage you to dig into those concepts further. Feel free to use this example as a basis to build on.