<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 'react';
const useFetch = (url, options = {}) => {
const [state, setState] = useState({
data: null,
error: null,
loading: true,
});
const cacheRef = useRef(new Map());
const abortControllerRef = useRef(null);
const fetchData = useCallback(async () => {
// 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) => ({
...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 !== 'AbortError') {
setState({
data: null,
error: err.message,
loading: false,
});
}
}
}, [url, options]);
useEffect(() => {
fetchData();
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [fetchData]);
const refetch = useCallback(() => {
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 'react';
const useFetchWithRetry = (url, options = {}) => {
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) => {
if (cacheRef.current.has(url) && 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) => ({
...prev,
loading: true,
error: retryCount > 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 === 'AbortError') {
return;
}
if (retryCount < maxRetries) {
const delay = retryDelay * Math.pow(backoffMultiplier, retryCount);
retryTimeoutRef.current = setTimeout(() => {
fetchData(retryCount + 1);
}, delay);
} else {
setState({
data: null,
error: ${err.message} (Failed after ${maxRetries} retries),
loading: false,
retryCount,
});
}
}
},
[url, options, maxRetries, retryDelay, backoffMultiplier]
);
useEffect(() => {
fetchData();
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
}
};
}, [fetchData]);
const refetch = useCallback(() => {
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 'react';
class CacheEntry {
constructor(data, ttl = 5 60 1000) {
this.data = data;
this.createdAt = Date.now();
this.ttl = ttl;
}
isExpired() {
return Date.now() - this.createdAt > this.ttl;
}
}
const useFetchWithCache = (url, options = {}) => {
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(() => {
const cached = cacheRef.current.get(url);
if (cached && !cached.isExpired()) {
return cached.data;
}
if (cached && cached.isExpired()) {
cacheRef.current.delete(url);
}
return null;
}, [url]);
const fetchData = useCallback(async () => {
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) => ({
...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 !== 'AbortError') {
setState({
data: null,
error: err.message,
loading: false,
isCached: false,
});
}
}
}, [url, options, cacheTTL, getCachedData]);
useEffect(() => {
fetchData();
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [fetchData]);
const refetch = useCallback(() => {
cacheRef.current.delete(url);
fetchData();
}, [url, fetchData]);
const clearCache = useCallback(() => {
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 "cached" 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 'react';
import useFetchWithCache from './useFetchWithCache';
const UsersList = () => {
const [searchQuery, setSearchQuery] = useState('');
const baseUrl = 'https://api.example.com/users';
const url = searchQuery ? ${baseUrl}?q=${searchQuery} : baseUrl;
const { data, error, loading, isCached, refetch } = useFetchWithCache(url, {
cacheTTL: 3 60 1000, // 3 minutos
});
return (
<div className="users-container">
<h2>Users</h2>
<div className="search-bar">
<input
type="text"
placeholder="Search users..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{loading && <div className="spinner">Loading...</div>}
{error && (
<div className="error-message">
<p>{error}</p>
<button onClick={refetch}>Try Again</button>
</div>
)}
{data && (
<div className="users-list">
{isCached && <span className="cache-badge">From Cache</span>}
<ul>
{data.map((user) => (
<li key={user.id}>
<strong>{user.name}</strong> - {user.email}
</li>
))}
</ul>
<button onClick={refetch}>Refresh Data</button>
</div>
)}
</div>
);
};
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 "Refresh Data", 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 "Try Again" 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><!-- FIM --></p>