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 useEffect e useState, 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 data, error, loading e métodos para refazer requisições. A implementação abaixo é funcional e pronta para produção:
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;
Entendendo Cada Componente
O hook mantém um Map em cacheRef para armazenar respostas anteriores — isso elimina requisições desnecessárias quando o componente remonta ou quando vários componentes usam o mesmo URL. O AbortController garante que requisições antigas sejam canceladas quando o componente desmontar ou uma nova requisição começar, evitando memory leaks e race conditions.
O método refetch deleta a entrada do cache antes de chamar fetchData novamente, forçando uma requisição fresca. Isso é crucial quando você precisa atualizar dados manualmente. Observe que fetchData está dentro de useCallback com dependências de url e options, o que significa que mudanças nesses valores disparam novas requisições automaticamente.
Implementando Retry Automático com Backoff Exponencial
O Problema com Falhas de Rede
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.
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;
Como o Backoff Exponencial Funciona
Quando uma requisição falha, ao invés de tentar imediatamente, esperamos um tempo. A espera cresce exponencialmente: se retryDelay é 1000ms e backoffMultiplier é 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 maxRetries define quantas tentativas fazemos antes de desistir e mostrar o erro ao usuário.
Note que o retryCount é passado como parâmetro na função recursiva, mantendo o controle de quantas tentativas já foram feitas. Quando o retry é bem-sucedido, retryCount volta a zero e o cache é atualizado. Se falhar após maxRetries tentativas, a mensagem de erro informa exatamente isso ao usuário.
Sistema de Cache Avançado com Expiração
Limitações do Cache Simples
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.
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;
Classe CacheEntry e Gerenciamento de TTL
Criamos uma classe CacheEntry que armazena não apenas os dados, mas também quando foram armazenados e por quanto tempo são válidos. O método isExpired() 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.
O hook agora retorna um estado adicional isCached, 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 clearCache() permite limpar todo o cache manualmente, útil em scenarios de logout ou reset de aplicação.
Exemplo Prático: Integrando o Hook em um Componente Real
Padrão de Uso Completo
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:
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;
Comportamento Esperado
Quando o componente monta, useFetchWithCache verifica se há dados em cache válidos. Se houver, mostra imediatamente com isCached: true. Se não, faz uma requisição. Quando o usuário digita na barra de busca, a URL muda, o hook detecta isso (porque url está nas dependências) e faz uma nova requisição. Se o usuário clicar em "Refresh Data", a função refetch() limpa o cache e força uma requisição fresca.
O estado loading permite mostrar um spinner enquanto a requisição está em andamento. Se ocorrer erro, error 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.
Conclusão
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 — gerenciamento de estado centralizado, retry automático com backoff exponencial e cache inteligente com expiração — 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.