Busca de dados em React, além do básico

Um dos grandes desafios na construção de uma aplicação em React é determinar um padrão de código para buscar dados de um servidor. Vamos ver algumas opções neste post.

Murilo Campos Pereira

Murilo Campos Pereira

July 08, 2021 | leitura de 7 minutos

dev

Um fluxo extremamente comum em aplicações web de página única é carregar um componente, buscar algum dado - geralmente mostrando algo para indicar o carregamento para o usuário, como uma barra de progresso - e em seguida exibir o resultado da busca. Uma implementação simples desse fluxo em React seria algo assim:

const  App:  React.FC  = () => {

 const [data, setData] =  useState<Repository>();

 useEffect(() => {

   fetch('https://api.github.com/repos/facebook/react').then(

     async  response  => {

       const  parsedData  =  await  response.json();

       setData(parsedData);

     },

   );

 }, []);

 return <div>{data  &&  data.full_name}</div>;

};

Buscamos as informações de um repositório na API do Github em um hook useEffect assim que o componente é "montado" (como no abandonado componentDidMount) e exibimos o nome na tela, bem simples. Podemos melhorar isso adicionando mais dois estados booleanos, um para indicar que a busca está em andamento e outro para indicar se houve algum erro na requisição:

const  App:  React.FC  = () => {

 const [data, setData] =  useState<Repository>();

 const [loading, setLoading] =  useState(true);

 const [error, setError] =  useState(false);

 useEffect(() => {

   fetch('https://api.github.com/repos/facebook/react').then(

     async  response  => {

       const  parsedData  =  await  response.json();

       setData(parsedData);

       setLoading(false);

     },

     async () => {

       setLoading(false);

       setError(true);

     },

   );

 }, []);

 if (loading) {

   return <div>Loading...</div>;

 }

 if (error) {

   return <div>There was an error while fetching your data.</div>;

 }

 return <div>{data  &&  data.full_name}</div>;

};

Agora temos uma implementação um pouco mais robusta. Talvez, você esteja pensando: "Mas já temos 3 estados e 2 ifs só para uma busca? E se precisar também consultar informações sobre o clima em outra API? E se, just for fun, eu quiser exibir um Pokémon da PokeAPI?" A complexidade e a quantidade de coisas que teríamos que lembrar (e facilmente esquecer) de tratar cresce demais, então vamos recordar do conceito de DRY e isolar toda essa lógica em uma solução reutilizável e genérica.

🪝Hooks 

Hooks são perfeitos para extrair a lógica de um componente em funções reutilizáveis - que é justamente o que queremos fazer. Se quiser entender como isso funciona, pode dar uma olhada nesse artigo (em inglês). Uma solução elegante, então, seria extrair toda aquela lógica que fizemos para um hook.

const  useAsync  = (asyncFunction, immediate  =  true) => {

 const [status, setStatus] =  useState('idle');

 const [value, setValue] =  useState(null);

 const [error, setError] =  useState(null);

 const  execute  =  useCallback(() => {

   setStatus('pending');

   setValue(null);

   setError(null);

   return  asyncFunction().then(

     async  response  => {

       const  parsedResponse  =  await  response.json();

       setValue(parsedResponse);

       setStatus('success');

     },

     error  => {

       setError(error);

       setStatus('error');

     },

   );

 }, [asyncFunction]);

 useEffect(() => {

   if (immediate) {

     execute();

   }

 }, [execute, immediate]);

 return {execute, status, value, error};

};

Vamos entender esse hook juntos: A primeira mudança importante foi deixar de usar booleans para controlar o status das requisições. O ideal mesmo seria isso tudo estar em um reducer. Depois, temos states para o retorno da requisição, um booleano para indicar se houve erro na busca de dados, uma função execute que controla as chamadas da asyncFunction recebida como parâmetro e, por fim, um hook useEffect que por padrão dispara a função execute assim que o componente for "montado" (como no nosso primeiro exemplo). Se o usuário quiser disparar a requisição programáticamente, por exemplo, após o clique de um botão, basta enviar o parâmetro immediate como false e a busca de dados só acontecerá quando o usuário chamar a função execute diretamente, uma prática conhecida como lazy query.

Agora temos um hook que lida com toda essa dor de cabeça. Se quisermos buscar um pokémon basta criar uma função fetchPokemon e mandar pro hook. Mas, vocês concordam comigo que tudo o que guardamos no nosso estado data até agora é basicamente um cache das respostas?

App state vs Server state 

Nem todo estado é igual. Nos nossos exemplos até agora tivemos estados que controlam o status e guardam os dados das requisições. Porém, normalmente temos estados de campos de um formulário, se um modal ou menu está aberto ou não, etc. E em geral podemos listar (bem resumidamente) dois tipos de estados:

  • Local state: Dados que pertencem ao web app e que estão sob seu controle (modal aberta/fechada, qual aba está ativa, campos de um formulário)

  • Server state: Caches de APIs, não estão sob o controle do seu web app e podem ser modificados por N pessoas a qualquer momento

Não podemos tratar server state da mesma forma que local state. No nosso exemplo anterior, o que acontece se o nome do repositório mudar? Ao server state se aplicam todos os problemas de cache invalidation.

"There are only two hard things in Computer Science: cache invalidation and naming things." - Phil Karlton

Para ter uma solução completa, você teria que pensar em como controlar em quanto tempo esse dado se torna "obsoleto" (conhecido como time to live, ou TTL), como atualizar os dados quando se tornarem obsoletos, como invalidar só algumas das chamadas que você fez e não todas (por exemplo, atualizar as informações do repositório mas não do Pokémon), paginação, etc.  Felizmente, na comunidade javascript) já tem várias bibliotecas que nos ajudam com isso, a minha biblioteca de preferência é a react-query por alguns motivos:

  • Tem uma abordagem bem agnóstica (Apollo resolve vários dos problemas citados mas é muito atrelada ao GraphQL);
  • Vem com DevTools que ajudam muito no desenvolvimento;
  • O autor é muito ativo na comunidade e tem várias bibliotecas muito utilizadas (como react-table).

Refatorando aquele nosso exemplo para usar a react-query é muito simples, fica quase igual. Tudo o que precisamos fazer é passar uma chave (que será usada para controlar o cache) e uma função que retorna os dados que você precisa.

const  App:  React.FC  = () => {

 const  fetchData  =  async () => {

   const  response  =  await  fetch('https://api.github.com/repos/facebook/react');

   return  response.json();

 };

 const {data, isFetching, isError} =  useQuery('repository', fetchData);

 return (

   <div>

     {isFetching  && <div>Loading...</div>}

     {isError  && <div>There was an error while fetching the data.</div>}

     {data  && <div>{data.full_name}</div>}

   </div>

 );

};

A mudança foi pequena mas a flexibilidade é imensa, podemos extrair isso em um outro hook chamado useRepository (ou usePokemon) e centralizar todas as chamadas em hooks personalizados, usar mutations para alterar dados e automaticamente invalidar o cache dos dados referentes a mutation, trabalhar com paginação infinita usando useInfiniteQuery, que integra perfeitamente com uma FlatList se você estiver em um projeto em React Native.

Extrair todas as chamadas do componente não só deixa o código mais legível, como também diminui o acoplamento (um dos fundamentos do clean code) e facilita a implementação de testes unitários, o que melhora muito a qualidade do seu software. É muito fácil mockar o retorno de um hook personalizado para fazer seus testes com o jest/testing-library, e ter uma boa cobertura de testes te dá muita segurança para fazer qualquer refatoração/alteração nos seus componentes.

Conclusão

A ideia desse post foi levantar essa bola de gerenciamento de server state que muitas vezes passa batido na correria das entregas. É muito comum não nos atentarmos ao problema que é deixar muito acoplado ao componente essas chamadas externas (e quando vemos, já é tarde demais). Junto ao que já falamos aqui, podemos trocar o fetch por uma biblioteca mais completa como Axios, assim conseguimos adicionar interceptors para revalidar um token JWT ao receber uma resposta de "não autorizado", por exemplo. Sua imaginação é o limite!

O importante é manter em mente que local state e server state são coisas completamente diferentes, e devem ser tratadas de maneira diferente. Como gerenciar o local state no projeto é uma decisão do time, eu diria que em 90% dos casos os hooks do próprio React - useState, useReducer e useContext -  que já são mais do que o suficiente. Redux é exagero. Mas, no próximo projeto que você for atuar, pensem bem em como gerenciar as chamadas externas (e o server state) antes que isso vire uma enorme dívida técnica.

Murilo Campos Pereira
Murilo Campos Pereira

Software Engineer | Entusiasta de functional reactive/observable programming e utility-first CSS, formado em ciência da computação. Nas horas livres jogo RPG e ando de bicicleta.

LinkedIn