React & Frontend

Hooks para Fetch: Abstraindo Ciclo de Requisição, Cache e Retry na Prática

13 min de leitura

Hooks para Fetch: Abstraindo Ciclo de Requisição, Cache e Retry na Prática

Entendendo o Problema: Por Que Precisamos de Hooks para Fetch Quando começamos a trabalhar com requisições HTTP em React, rapidamente nos deparamos com um padrão repetitivo: precisamos gerenciar estado para dados, estado para erros, estado para loading, lidar com efeitos colaterais e, frequentemente, refazer requisições quando algo dá errado. Multiplicar esse código por vários componentes leva a inconsistências, bugs difíceis de rastrear e manutenção cara. A maior parte dos desenvolvedores implementa isso diretamente nos componentes usando e , resultando em lógica espalhada e difícil de testar. Um hook customizado para fetch não é apenas uma questão de elegância — é sobre centralizar a lógica, garantir comportamentos previsíveis e economizar centenas de linhas de código repetidas. Nesta aula, vamos construir um sistema robusto que abstrai completamente o ciclo de requisição, adiciona cache inteligente e implementa retry automático. Construindo o Hook Base: useFetch A Estrutura Fundamental Vamos começar simples, mas pensando em escalabilidade. O hook deve retornar um objeto com , ,

<h2>Entendendo o Problema: Por Que Precisamos de Hooks para Fetch</h2>

<p>Quando começamos a trabalhar com requisições HTTP em React, rapidamente nos deparamos com um padrão repetitivo: precisamos gerenciar estado para dados, estado para erros, estado para loading, lidar com efeitos colaterais e, frequentemente, refazer requisições quando algo dá errado. Multiplicar esse código por vários componentes leva a inconsistências, bugs difíceis de rastrear e manutenção cara.</p>

<p>A maior parte dos desenvolvedores implementa isso diretamente nos componentes usando <code>useEffect</code> e <code>useState</code>, resultando em lógica espalhada e difícil de testar. Um hook customizado para fetch não é apenas uma questão de elegância — é sobre centralizar a lógica, garantir comportamentos previsíveis e economizar centenas de linhas de código repetidas. Nesta aula, vamos construir um sistema robusto que abstrai completamente o ciclo de requisição, adiciona cache inteligente e implementa retry automático.</p>

<h2>Construindo o Hook Base: useFetch</h2>

<h3>A Estrutura Fundamental</h3>

<p>Vamos começar simples, mas pensando em escalabilidade. O hook deve retornar um objeto com <code>data</code>, <code>error</code>, <code>loading</code> e métodos para refazer requisições. A implementação abaixo é funcional e pronta para produção:</p>

<pre><code class="language-javascript">import { useState, useCallback, useRef, useEffect } from &#039;react&#039;;

const useFetch = (url, options = {}) =&gt; {

const [state, setState] = useState({

data: null,

error: null,

loading: true,

});

const cacheRef = useRef(new Map());

const abortControllerRef = useRef(null);

const fetchData = useCallback(async () =&gt; {

// Verifica cache antes de fazer requisição

if (cacheRef.current.has(url)) {

setState({

data: cacheRef.current.get(url),

error: null,

loading: false,

});

return;

}

// Cancela requisição anterior se existir

if (abortControllerRef.current) {

abortControllerRef.current.abort();

}

abortControllerRef.current = new AbortController();

setState((prev) =&gt; ({

...prev,

loading: true,

error: null,

}));

try {

const response = await fetch(url, {

signal: abortControllerRef.current.signal,

...options,

});

if (!response.ok) {

throw new Error(HTTP ${response.status}: ${response.statusText});

}

const data = await response.json();

cacheRef.current.set(url, data);

setState({

data,

error: null,

loading: false,

});

} catch (err) {

if (err.name !== &#039;AbortError&#039;) {

setState({

data: null,

error: err.message,

loading: false,

});

}

}

}, [url, options]);

useEffect(() =&gt; {

fetchData();

return () =&gt; {

if (abortControllerRef.current) {

abortControllerRef.current.abort();

}

};

}, [fetchData]);

const refetch = useCallback(() =&gt; {

cacheRef.current.delete(url);

fetchData();

}, [url, fetchData]);

return {

...state,

refetch,

};

};

export default useFetch;</code></pre>

<h3>Entendendo Cada Componente</h3>

<p>O hook mantém um <code>Map</code> em <code>cacheRef</code> para armazenar respostas anteriores — isso elimina requisições desnecessárias quando o componente remonta ou quando vários componentes usam o mesmo URL. O <code>AbortController</code> garante que requisições antigas sejam canceladas quando o componente desmontar ou uma nova requisição começar, evitando memory leaks e race conditions.</p>

<p>O método <code>refetch</code> deleta a entrada do cache antes de chamar <code>fetchData</code> novamente, forçando uma requisição fresca. Isso é crucial quando você precisa atualizar dados manualmente. Observe que <code>fetchData</code> está dentro de <code>useCallback</code> com dependências de <code>url</code> e <code>options</code>, o que significa que mudanças nesses valores disparam novas requisições automaticamente.</p>

<h2>Implementando Retry Automático com Backoff Exponencial</h2>

<h3>O Problema com Falhas de Rede</h3>

<p>Nem toda requisição que falha deve ser considerada permanentemente perdida. Erros de rede temporários ou falhas de timeout são comuns em aplicações reais. Ao invés de deixar o usuário vendo um erro, podemos tentar novamente com esperas progressivas — uma estratégia chamada backoff exponencial.</p>

<pre><code class="language-javascript">import { useState, useCallback, useRef, useEffect } from &#039;react&#039;;

const useFetchWithRetry = (url, options = {}) =&gt; {

const [state, setState] = useState({

data: null,

error: null,

loading: true,

retryCount: 0,

});

const cacheRef = useRef(new Map());

const abortControllerRef = useRef(null);

const retryTimeoutRef = useRef(null);

const { maxRetries = 3, retryDelay = 1000, backoffMultiplier = 2 } = options;

const fetchData = useCallback(

async (retryCount = 0) =&gt; {

if (cacheRef.current.has(url) &amp;&amp; retryCount === 0) {

setState({

data: cacheRef.current.get(url),

error: null,

loading: false,

retryCount: 0,

});

return;

}

if (abortControllerRef.current) {

abortControllerRef.current.abort();

}

abortControllerRef.current = new AbortController();

setState((prev) =&gt; ({

...prev,

loading: true,

error: retryCount &gt; 0 ? prev.error : null,

retryCount,

}));

try {

const response = await fetch(url, {

signal: abortControllerRef.current.signal,

...options,

});

if (!response.ok) {

throw new Error(HTTP ${response.status}: ${response.statusText});

}

const data = await response.json();

cacheRef.current.set(url, data);

setState({

data,

error: null,

loading: false,

retryCount: 0,

});

} catch (err) {

if (err.name === &#039;AbortError&#039;) {

return;

}

if (retryCount &lt; maxRetries) {

const delay = retryDelay * Math.pow(backoffMultiplier, retryCount);

retryTimeoutRef.current = setTimeout(() =&gt; {

fetchData(retryCount + 1);

}, delay);

} else {

setState({

data: null,

error: ${err.message} (Failed after ${maxRetries} retries),

loading: false,

retryCount,

});

}

}

},

[url, options, maxRetries, retryDelay, backoffMultiplier]

);

useEffect(() =&gt; {

fetchData();

return () =&gt; {

if (abortControllerRef.current) {

abortControllerRef.current.abort();

}

if (retryTimeoutRef.current) {

clearTimeout(retryTimeoutRef.current);

}

};

}, [fetchData]);

const refetch = useCallback(() =&gt; {

cacheRef.current.delete(url);

fetchData(0);

}, [url, fetchData]);

return {

...state,

refetch,

};

};

export default useFetchWithRetry;</code></pre>

<h3>Como o Backoff Exponencial Funciona</h3>

<p>Quando uma requisição falha, ao invés de tentar imediatamente, esperamos um tempo. A espera cresce exponencialmente: se <code>retryDelay</code> é 1000ms e <code>backoffMultiplier</code> é 2, as tentativas acontecem em 1000ms, 2000ms, 4000ms, 8000ms e assim por diante. Isso reduz a carga no servidor durante problemas e dá tempo para a rede se recuperar. O parâmetro <code>maxRetries</code> define quantas tentativas fazemos antes de desistir e mostrar o erro ao usuário.</p>

<p>Note que o <code>retryCount</code> é passado como parâmetro na função recursiva, mantendo o controle de quantas tentativas já foram feitas. Quando o retry é bem-sucedido, <code>retryCount</code> volta a zero e o cache é atualizado. Se falhar após <code>maxRetries</code> tentativas, a mensagem de erro informa exatamente isso ao usuário.</p>

<h2>Sistema de Cache Avançado com Expiração</h2>

<h3>Limitações do Cache Simples</h3>

<p>O cache que implementamos até agora é perpetuado — uma vez que os dados estão lá, eles nunca expiram. Para muitas aplicações, isso é inadequado. Dados podem ficar desatualizados, e às vezes você quer forçar uma atualização após certo tempo. Um sistema de cache com TTL (Time To Live) é muito mais realista.</p>

<pre><code class="language-javascript">import { useState, useCallback, useRef, useEffect } from &#039;react&#039;;

class CacheEntry {

constructor(data, ttl = 5 60 1000) {

this.data = data;

this.createdAt = Date.now();

this.ttl = ttl;

}

isExpired() {

return Date.now() - this.createdAt &gt; this.ttl;

}

}

const useFetchWithCache = (url, options = {}) =&gt; {

const [state, setState] = useState({

data: null,

error: null,

loading: true,

isCached: false,

});

const cacheRef = useRef(new Map());

const abortControllerRef = useRef(null);

const { cacheTTL = 5 60 1000 } = options;

const getCachedData = useCallback(() =&gt; {

const cached = cacheRef.current.get(url);

if (cached &amp;&amp; !cached.isExpired()) {

return cached.data;

}

if (cached &amp;&amp; cached.isExpired()) {

cacheRef.current.delete(url);

}

return null;

}, [url]);

const fetchData = useCallback(async () =&gt; {

const cachedData = getCachedData();

if (cachedData !== null) {

setState({

data: cachedData,

error: null,

loading: false,

isCached: true,

});

return;

}

if (abortControllerRef.current) {

abortControllerRef.current.abort();

}

abortControllerRef.current = new AbortController();

setState((prev) =&gt; ({

...prev,

loading: true,

error: null,

}));

try {

const response = await fetch(url, {

signal: abortControllerRef.current.signal,

...options,

});

if (!response.ok) {

throw new Error(HTTP ${response.status});

}

const data = await response.json();

cacheRef.current.set(url, new CacheEntry(data, cacheTTL));

setState({

data,

error: null,

loading: false,

isCached: false,

});

} catch (err) {

if (err.name !== &#039;AbortError&#039;) {

setState({

data: null,

error: err.message,

loading: false,

isCached: false,

});

}

}

}, [url, options, cacheTTL, getCachedData]);

useEffect(() =&gt; {

fetchData();

return () =&gt; {

if (abortControllerRef.current) {

abortControllerRef.current.abort();

}

};

}, [fetchData]);

const refetch = useCallback(() =&gt; {

cacheRef.current.delete(url);

fetchData();

}, [url, fetchData]);

const clearCache = useCallback(() =&gt; {

cacheRef.current.clear();

}, []);

return {

...state,

refetch,

clearCache,

};

};

export default useFetchWithCache;</code></pre>

<h3>Classe CacheEntry e Gerenciamento de TTL</h3>

<p>Criamos uma classe <code>CacheEntry</code> que armazena não apenas os dados, mas também quando foram armazenados e por quanto tempo são válidos. O método <code>isExpired()</code> verifica se o tempo de vida expirou comparando o tempo atual com o tempo de criação. Isso permite que você defina diferentes TTLs para diferentes requisições — dados críticos podem ter TTL curto, enquanto dados estáveis podem ter TTL longo.</p>

<p>O hook agora retorna um estado adicional <code>isCached</code>, que informa ao componente se os dados vieram do cache ou de uma requisição nova. Isso é útil para mostrar indicadores visuais, como um badge &quot;cached&quot; ou comportamentos diferentes em relação à atualização de dados. O método <code>clearCache()</code> permite limpar todo o cache manualmente, útil em scenarios de logout ou reset de aplicação.</p>

<h2>Exemplo Prático: Integrando o Hook em um Componente Real</h2>

<h3>Padrão de Uso Completo</h3>

<p>Agora que temos um hook robusto, vamos ver como usá-lo em um componente real. Imagine uma lista de usuários que precisa ser fetched, com suporte para retry e cache:</p>

<pre><code class="language-javascript">import React, { useState } from &#039;react&#039;;

import useFetchWithCache from &#039;./useFetchWithCache&#039;;

const UsersList = () =&gt; {

const [searchQuery, setSearchQuery] = useState(&#039;&#039;);

const baseUrl = &#039;https://api.example.com/users&#039;;

const url = searchQuery ? ${baseUrl}?q=${searchQuery} : baseUrl;

const { data, error, loading, isCached, refetch } = useFetchWithCache(url, {

cacheTTL: 3 60 1000, // 3 minutos

});

return (

&lt;div className=&quot;users-container&quot;&gt;

&lt;h2&gt;Users&lt;/h2&gt;

&lt;div className=&quot;search-bar&quot;&gt;

&lt;input

type=&quot;text&quot;

placeholder=&quot;Search users...&quot;

value={searchQuery}

onChange={(e) =&gt; setSearchQuery(e.target.value)}

/&gt;

&lt;/div&gt;

{loading &amp;&amp; &lt;div className=&quot;spinner&quot;&gt;Loading...&lt;/div&gt;}

{error &amp;&amp; (

&lt;div className=&quot;error-message&quot;&gt;

&lt;p&gt;{error}&lt;/p&gt;

&lt;button onClick={refetch}&gt;Try Again&lt;/button&gt;

&lt;/div&gt;

)}

{data &amp;&amp; (

&lt;div className=&quot;users-list&quot;&gt;

{isCached &amp;&amp; &lt;span className=&quot;cache-badge&quot;&gt;From Cache&lt;/span&gt;}

&lt;ul&gt;

{data.map((user) =&gt; (

&lt;li key={user.id}&gt;

&lt;strong&gt;{user.name}&lt;/strong&gt; - {user.email}

&lt;/li&gt;

))}

&lt;/ul&gt;

&lt;button onClick={refetch}&gt;Refresh Data&lt;/button&gt;

&lt;/div&gt;

)}

&lt;/div&gt;

);

};

export default UsersList;</code></pre>

<h3>Comportamento Esperado</h3>

<p>Quando o componente monta, <code>useFetchWithCache</code> verifica se há dados em cache válidos. Se houver, mostra imediatamente com <code>isCached: true</code>. Se não, faz uma requisição. Quando o usuário digita na barra de busca, a URL muda, o hook detecta isso (porque <code>url</code> está nas dependências) e faz uma nova requisição. Se o usuário clicar em &quot;Refresh Data&quot;, a função <code>refetch()</code> limpa o cache e força uma requisição fresca.</p>

<p>O estado <code>loading</code> permite mostrar um spinner enquanto a requisição está em andamento. Se ocorrer erro, <code>error</code> contém a mensagem e o usuário pode clicar em &quot;Try Again&quot; para fazer outra tentativa. Tudo isso acontece sem uma linha sequer de código de gerenciamento de fetch dentro do componente — pura abstração.</p>

<h2>Conclusão</h2>

<p>Abstrair o ciclo de requisições em um hook reutilizável elimina código duplicado, reduz bugs e torna a manutenção exponencialmente mais fácil. Os três pilares que construímos — <strong>gerenciamento de estado centralizado</strong>, <strong>retry automático com backoff exponencial</strong> e <strong>cache inteligente com expiração</strong> — cobrem 95% dos cenários reais de fetch que você vai enfrentar. A chave está em pensar em componibilidade desde o início: o hook deve ser simples de usar no componente, mas poderoso o suficiente para lidar com edge cases internamente. Pratique implementando variações desses padrões (como adicionar suporte a POST/PUT, transformações de dados, ou persistência em localStorage) para consolidar o aprendizado.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API" target="_blank" rel="noopener noreferrer">MDN - Fetch API</a></li>

<li><a href="https://react.dev/reference/react" target="_blank" rel="noopener noreferrer">React Documentation - Hooks</a></li>

<li><a href="https://nodejs.org/api/abort_controller.html" target="_blank" rel="noopener noreferrer">Node.js AbortController</a></li>

<li><a href="https://javascript.info/async" target="_blank" rel="noopener noreferrer">JavaScript.info - Promises, async/await</a></li>

<li><a href="https://tanstack.com/query/latest" target="_blank" rel="noopener noreferrer">TanStack Query - Advanced Caching Strategy</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em React & Frontend

Monorepo de Componentes React: Storybook, Chromatic e Releases: Do Básico ao Avançado
Monorepo de Componentes React: Storybook, Chromatic e Releases: Do Básico ao Avançado

O Que é um Monorepo de Componentes React Um monorepo (repositório monolítico)...

React Fiber: Arquitetura Interna, Reconciliation e Rendering Phases: Do Básico ao Avançado
React Fiber: Arquitetura Interna, Reconciliation e Rendering Phases: Do Básico ao Avançado

React Fiber: Arquitetura Interna, Reconciliation e Rendering Phases React Fib...

Dominando React DevTools Avançado: Profiler, Flamegraph e Otimização Guiada em Projetos Reais
Dominando React DevTools Avançado: Profiler, Flamegraph e Otimização Guiada em Projetos Reais

O que é React DevTools e Por Que Importa React DevTools é uma extensão do nav...