Criando aplicativos React com o Redux Toolkit e RTK Query
Publicados: 2022-03-11Você já quis usar o Redux com recursos como o React Query fornece? Agora você pode, usando o Redux Toolkit e sua mais recente adição: RTK Query.
RTK Query é uma ferramenta avançada de busca de dados e armazenamento em cache do lado do cliente. Sua funcionalidade é semelhante ao React Query, mas tem o benefício de ser integrado diretamente ao Redux. Para interação da API, os desenvolvedores normalmente usam módulos de middleware assíncronos como Thunk ao trabalhar com Redux. Tal abordagem limita a flexibilidade; assim, os desenvolvedores do React agora têm uma alternativa oficial da equipe Redux que cobre todas as necessidades avançadas da comunicação cliente/servidor de hoje.
Este artigo demonstra como o RTK Query pode ser usado em cenários do mundo real e cada etapa inclui um link para um diff de confirmação para destacar a funcionalidade adicionada. Um link para a base de código completa aparece no final.
Caldeira e Configuração
Diferença de inicialização do projeto
Primeiro, precisamos criar um projeto. Isso é feito usando o modelo Create React App (CRA) para uso com TypeScript e Redux:
npx create-react-app . --template redux-typescriptEle possui várias dependências que precisaremos ao longo do caminho, sendo as mais notáveis:
- Kit de ferramentas Redux e consulta RTK
- IU do material
- Lodash
- Formik
- Reagir Roteador
Também inclui a capacidade de fornecer configuração personalizada para webpack. Normalmente, o CRA não suporta essas habilidades, a menos que você ejete.
Inicialização
Uma rota muito mais segura do que ejetar é usar algo que possa modificar a configuração, principalmente se essas modificações forem pequenas. Este clichê usa react-app-rewired e customize-cra para realizar essa funcionalidade para introduzir uma configuração personalizada do 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 };Isso melhora a experiência do desenvolvedor ao permitir importações. Por exemplo:
import { omit } from 'lodash'; import { Box } from '@material-ui/core';Essas importações geralmente resultam em um tamanho maior do pacote, mas com a funcionalidade de reescrita que configuramos, elas funcionarão assim:
import omit from 'lodash/omit'; import Box from '@material-ui/core/Box';Configuração
Diferença de configuração do Redux
Como todo o aplicativo é baseado em Redux, após a inicialização, precisaremos definir a configuração da loja:
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;Além da configuração de armazenamento padrão, adicionaremos a configuração para uma ação de estado de redefinição global que é útil em aplicativos do mundo real, tanto para os próprios aplicativos quanto para teste:
import { createAction } from '@reduxjs/toolkit'; export const RESET_STATE_ACTION_TYPE = 'resetState'; export const resetStateAction = createAction( RESET_STATE_ACTION_TYPE, () => { return { payload: null }; } );Em seguida, adicionaremos middleware personalizado para lidar com respostas 401 simplesmente limpando a loja:
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); };Até agora tudo bem. Criamos o clichê e configuramos o Redux. Agora vamos adicionar algumas funcionalidades.
Autenticação
Recuperando a Diferença do Token de Acesso
A autenticação é dividida em três etapas para simplificar:
- Adicionando definições de API para recuperar um token de acesso
- Adicionando componentes para lidar com o fluxo de autenticação da Web do GitHub
- Finalizando a autenticação fornecendo componentes utilitários para fornecer ao usuário todo o aplicativo
Nesta etapa, adicionamos a capacidade de recuperar o token de acesso.
A ideologia do RTK Query determina que todas as definições de API apareçam em um só lugar, o que é útil ao lidar com aplicativos de nível empresarial com vários endpoints. Em uma aplicação corporativa, é muito mais fácil contemplar a API integrada, assim como o cache do cliente, quando tudo está em um só lugar.
O RTK Query apresenta ferramentas para geração automática de definições de API usando padrões OpenAPI ou GraphQL. Essas ferramentas ainda estão em sua infância, mas estão sendo desenvolvidas ativamente. Além disso, essa biblioteca foi projetada para fornecer uma excelente experiência de desenvolvedor com o TypeScript, que está se tornando cada vez mais a escolha para aplicativos corporativos devido à sua capacidade de melhorar a capacidade de manutenção.
No nosso caso, as definições residirão na pasta API. Por enquanto, exigimos apenas isso:
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 } }); }, }), }), });A autenticação do GitHub é fornecida por meio de um servidor de autenticação de código aberto, que é hospedado separadamente no Heroku devido aos requisitos da API do GitHub.
O servidor de autenticação
Embora não seja necessário para este projeto de exemplo, os leitores que desejam hospedar sua própria cópia do servidor de autenticação precisarão:
- Crie um aplicativo OAuth no GitHub para gerar seu próprio ID de cliente e segredo.
- Forneça detalhes do GitHub ao servidor de autenticação por meio das variáveis de ambiente
GITHUB_CLIENT_IDeGITHUB_SECRET. - Substitua o valor
baseUrldo endpoint de autenticação nas definições de API acima. - No lado React, substitua o parâmetro
client_idno próximo exemplo de código.
A próxima etapa é adicionar componentes que usam essa API. Devido aos requisitos do fluxo da aplicação web do GitHub, precisaremos de um componente de login responsável por redirecionar para o 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; Depois que o GitHub redirecionar de volta ao nosso aplicativo, precisaremos de uma rota para manipular o código e recuperar access_token com base nele:
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]); Se você já usou React Query, o mecanismo para interagir com a API é semelhante para RTK Query. Isso fornece alguns recursos interessantes graças à integração do Redux que observaremos à medida que implementamos recursos adicionais. Para access_token , no entanto, ainda precisamos salvá-lo na loja manualmente despachando uma ação:
dispatch(authSlice.actions.updateAccessToken(accessToken));Fazemos isso pela capacidade de persistir o token entre os recarregamentos da página. Tanto para persistência quanto para capacidade de despachar a ação, precisamos definir uma configuração de armazenamento para nosso recurso de autenticação.
Por convenção, o Redux Toolkit se refere a eles como fatias:
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);Há mais um requisito para que o código anterior funcione. Cada API deve ser fornecida como um redutor para configuração da loja, e cada API vem com seu próprio middleware, que você deve 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; É isso! Agora nosso aplicativo está recuperando access_token e estamos prontos para adicionar mais recursos de autenticação sobre ele.
Concluindo a autenticação
Concluindo a diferença de autenticação
A próxima lista de recursos para autenticação inclui:
- A capacidade de recuperar o usuário da API do GitHub e fornecê-lo para o restante do aplicativo.
- O utilitário para ter rotas acessíveis apenas quando autenticadas ou ao navegar como convidado.
Para adicionar a capacidade de recuperar o usuário, precisaremos de algum clichê da API. Ao contrário da API de autenticação, a API do GitHub precisará da capacidade de recuperar o token de acesso de nossa loja Redux e aplicá-lo a cada solicitação como um cabeçalho de autorização.
Na consulta RTK, isso é obtido criando uma 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();Estou usando axios aqui, mas outros clientes também podem ser usados.
A próxima etapa é definir uma API para recuperar informações do usuário do 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 nossa consulta base personalizada aqui, o que significa que cada solicitação no escopo de userApi incluirá um cabeçalho de autorização. Vamos ajustar a configuração da loja principal para que a API esteja disponível:
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;Em seguida, precisamos chamar essa API antes que nosso aplicativo seja renderizado. Para simplificar, vamos fazer isso de uma maneira que se assemelhe a como a funcionalidade de resolução funciona para rotas Angular para que nada seja renderizado até obtermos informações do usuário.
A ausência do usuário também pode ser tratada de maneira mais granular, fornecendo alguma interface do usuário antecipadamente para que o usuário tenha uma primeira renderização significativa mais rapidamente. Isso requer mais reflexão e trabalho e definitivamente deve ser abordado em um aplicativo pronto para produção.
Para fazer isso, precisamos definir um 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;O que isso faz é simples. Ele interage com a API do GitHub para obter informações do usuário e não renderiza filhos antes que a resposta esteja disponível. Agora, se envolvermos a funcionalidade do aplicativo com este componente, sabemos que as informações do usuário serão resolvidas antes que qualquer outra coisa seja renderizada:
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; Vamos passar para a parte mais elegante. Agora temos a capacidade de obter informações do usuário em qualquer lugar do aplicativo, mesmo que não tenhamos salvado essas informações do usuário na loja manualmente, como fizemos com access_token .

Quão? Criando um React Hook customizado simples para ele:
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; }; O RTK Query fornece a opção useQueryState para cada endpoint, o que nos dá a capacidade de recuperar o estado atual para esse endpoint.
Por que isso é tão importante e útil? Porque não precisamos escrever muita sobrecarga para gerenciar o código. Como bônus, temos uma separação entre os dados API/cliente no Redux pronto para uso.
Usar o RTK Query evita o incômodo. Ao combinar a busca de dados com o gerenciamento de estado, o RTK Query elimina a lacuna que estaria lá mesmo se usássemos o React Query. (Com o React Query, os dados obtidos devem ser acessados por componentes não relacionados na camada de interface do usuário.)
Como etapa final, definimos um componente de rota personalizado padrão que usa esse gancho para determinar se uma rota deve ser renderizada ou não:
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;Diferença de testes de autenticação
Não há nada inerentemente específico para RTK Query quando se trata de escrever testes para aplicativos React. Pessoalmente, sou a favor da abordagem de teste de Kent C. Dodds e de um estilo de teste que se concentra na experiência e na interação do usuário. Nada muda muito ao usar RTK Query.
Dito isto, cada etapa ainda incluirá seus próprios testes para demonstrar que um aplicativo escrito com RTK Query é perfeitamente testável.
Nota: O exemplo mostra minha opinião sobre como esses testes devem ser escritos em relação ao que testar, o que simular e quanta reutilização de código introduzir.
Repositórios de consulta RTK
Para mostrar o RTK Query, apresentaremos alguns recursos adicionais ao aplicativo para ver como ele se comporta em determinados cenários e como pode ser usado.
Diferença de Repositórios e Diferença de Testes
A primeira coisa que faremos é introduzir um recurso para repositórios. Esse recurso tentará imitar a funcionalidade da guia Repositórios que você pode experimentar no GitHub. Ele visitará seu perfil e terá a capacidade de pesquisar repositórios e classificá-los com base em determinados critérios. Há muitas alterações de arquivo introduzidas nesta etapa. Eu encorajo você a cavar nas partes que você está interessado.
Vamos adicionar as definições de API necessárias para cobrir a funcionalidade dos repositórios primeiro:
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 });Assim que estiver pronto, vamos apresentar um recurso de Repositório que consiste em Pesquisa/Grade/Paginação:
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;A interação com a API de repositórios é mais complexa do que encontramos até agora, então vamos definir ganchos personalizados que nos fornecerão a capacidade de:
- Obtenha argumentos para chamadas de API.
- Obtenha o resultado da API atual conforme armazenado no estado.
- Busque dados chamando os endpoints da 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(); };Ter esse nível de separação como uma camada de abstração é importante neste caso tanto do ponto de vista da legibilidade quanto devido aos requisitos da Consulta RTK.
Como você deve ter notado quando introduzimos um gancho que recupera dados do usuário usando useQueryState , tivemos que fornecer os mesmos argumentos que fornecemos para a chamada real da 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; }; Esse nulo que fornecemos como argumento está lá se chamamos useQuery ou useQueryState . Isso é necessário porque o RTK Query identifica e armazena em cache uma informação pelos argumentos que foram usados para recuperar essa informação em primeiro lugar.
Isso significa que precisamos ser capazes de obter argumentos necessários para uma chamada de API separadamente da chamada de API real a qualquer momento. Dessa forma, podemos usá-lo para recuperar o estado em cache dos dados da API sempre que precisarmos.
Há mais uma coisa que você precisa prestar atenção neste pedaço de código em nossa definição de API:
refetchOnMountOrArgChange: 60Por quê? Porque um dos pontos importantes ao usar bibliotecas como RTK Query é manipular o cache do cliente e a invalidação do cache. Isso é vital e também requer uma quantidade substancial de esforço, que pode ser difícil de fornecer dependendo da fase de desenvolvimento em que você está.
Achei o RTK Query muito flexível a esse respeito. O uso desta propriedade de configuração nos permite:
- Desative completamente o cache, o que é útil quando você deseja migrar para o RTK Query, evitando problemas de cache como etapa inicial.
- Introduza o cache baseado em tempo, um mecanismo de invalidação simples para usar quando você sabe que algumas informações podem ser armazenadas em cache por um período X de tempo.
Compromissos
Commits Diff e Tests Diff
Esta etapa adiciona mais funcionalidades à página do repositório, adicionando a capacidade de visualizar commits para cada repositório, paginar esses commits e filtrar por branch. Ele também tenta imitar a funcionalidade que você obteria em uma página do GitHub.
Introduzimos mais dois endpoints para obter branches e commits, além de hooks personalizados para esses endpoints, seguindo o estilo que estabelecemos durante a implementação dos repositórios:
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]); };Feito isso, agora podemos melhorar o UX pré-buscando dados de commits assim que alguém passar o mouse sobre o nome do repositório:
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:
- Manipulação de erros
- Mutações
- 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.
