Toda aplicação frontend faz parte de um sistema distribuído. Normalmente precisamos buscar e enviar informações para um servidor conectado a um banco de dados, e numa aplicação simples e pequena isso funciona sem grandes problemas.
Mas, à medida que nossa aplicação e o consumo de dados aumentam, precisamos começar a pensar em estratégias de melhorar a experiência do usuário e tornar as páginas mais rápidas, fluídas e dinâmicas.
O cache é uma dessas estratégias.
Afinal, por que precisamos buscar uma informação que já foi buscada pouco tempo atrás?
Com isso, podemos evitar requisições desnecessárias para o servidor e a exibição frequente de loaders na página.
Mas isso traz outras questões, como:
- Por quanto tempo podemos confiar que esse cache está atualizado?
- Como mantê-lo sincronizado com o banco de dados?
- Quando devemos atualizá-lo?
Existem bibliotecas que nos ajudam a implementar e lidar com esses problemas, como TanStack Query, SWR, RTK Query, Apollo Client (para GraphQL).
Neste artigo, veremos como podemos gerenciar esse ciclo, desde a primeira requisição até estratégias como invalidação, atualização direta, optimistic updates, polling e prefetching.
Requisitando os dados do servidor
Como exemplo para o nosso artigo, iremos usar uma página simples que busca e exibe uma lista de produtos.
Com React, podemos usar os hooks useEffect e useState para implementá-la dessa forma.
import { useEffect, useState } from "react";
export function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchProducts()
.then((data) => {
setProducts(data);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <p>Carregando produtos...</p>;
}
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — R$ {product.price}
</li>
))}
</ul>
);
}
Apesar de precisarmos lidar com vários cenários manualmente, como loading, armazenamento, tratamento de erros, para uma página simples, esse código funciona.
Esse é o fluxo:
A página é montada
↓
Buscamos os dados numa API - Exibimos um loader
↓
Armazenamos a resposta num estado local - Ocultamos o loader
↓
Exibimos os dados na interface
Mas imagine que o usuário vai para outra página e volta, com essa implementação, o usuário vai passar pelo mesmo fluxo de novo:
A página é montada
↓
Buscamos os dados numa API - Exibimos um loader
↓
Armazenamos a resposta num estado local - Ocultamos o loader
↓
Exibimos os dados na interface
Podemos melhorar a experiência do usuário movendo os dados do servidor para um cache no nível da aplicação com o TanStack Query (anteriormente conhecido como React Query).
Cache no nível da aplicação
Utilizar cache faz a interface parecer mais rápida porque podemos reutilizar a resposta da requisição anterior, sem a necessidade de buscá-la novamente e exibir um loader ou uma página em branco.
Antes de tudo, vamos instalar e configurar a biblioteca na nossa aplicação:
npm install @tanstack/react-query
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { ReactNode } from "react";
import { ProductList } from "./ProductList";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ProductList />
</QueryClientProvider>
);
}
Buscando dados com useQuery
Agora a lista pode ser carregada usando useQuery:
import { useQuery } from "@tanstack/react-query";
export function ProductList() {
const {
data: products,
error,
isPending,
isFetching,
} = useQuery({
queryKey: ["products"], // Identificação dos dados dentro do cache.
queryFn: fetchProducts, // Como esses dados serão buscados
});
if (isPending) {
return <p>Carregando produtos...</p>;
}
if (error) {
return <p>{error.message}</p>;
}
return (
<section>
{isFetching && <p>Atualizando produtos...</p>}
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — R$ {product.price}
</li>
))}
</ul>
</section>
);
}
Na primeira vez que o componente é renderizado:
- React Query procura
["products"]no cache. - Como os dados ainda não existem, executa
fetchProducts. - O componente entra no estado de carregamento inicial.
- A API responde.
- Os dados são armazenados no cache.
- Os componentes inscritos nessa query recebem o resultado.
Se o usuário sair da tela e voltar posteriormente, React Query poderá exibir imediatamente os dados armazenados.
Dependendo da configuração, a biblioteca também pode executar uma nova requisição em segundo plano para verificar se existem informações mais recentes.
Diferença entre dados Fresh e Stale
Os dados no cache do React Query podem ser considerados fresh ou stale.
Dados fresh ainda são considerados suficientemente recentes.
Enquanto estiverem fresh, não será necessário buscá-los novamente automaticamente.
Dados stale podem continuar sendo exibidos, mas React Query entende que eles podem precisar de atualização.
Essa distinção é importante porque permite mostrar o cache imediatamente e atualizar a informação em segundo plano.
Controlando a atualização com staleTime
O staleTime determina por quanto tempo os dados permanecem fresh no cache.
const productsQuery = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 30_000,
});
Nesse exemplo, os produtos são considerados fresh durante 30 segundos.
Durante esse intervalo, se o componente for desmontado e montado novamente, React Query pode reutilizar os dados sem executar imediatamente uma nova requisição.
Depois de 30 segundos, os dados passam a ser considerados stale. Eles não são apagados, apenas ficam elegíveis para revalidação.
Também poderíamos usar staleTime: 5 * 60 * 1000 para considerá-los fresh por cinco minutos.
E para dados que mudam muito raramente, podemos utilizar staleTime: Infinity.
Nesse caso, a query nunca se torna stale automaticamente. Uma atualização dependerá de invalidação manual ou de outra ação explícita.
Um erro comum é interpretar staleTime como o tempo até os dados serem removidos.
Ele não controla a exclusão; o staleTime controla apenas quando os dados deixam de ser considerados fresh.
O tempo de permanência de uma query sem uso é controlado por gcTime:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 30_000, // Dados são considerados fresh por 30 segundos
gcTime: 5 * 60 * 1000, // A query inativa pode ser removida do cache após 5 minutos sem observadores.
});
Diferença entre isPending, isLoading e isFetching
Os estados de carregamento, isPending, isLoading e isFetching precisam ser usados de maneira diferente. Quando a query ainda está no estado pending, mostramos um loader usando o estado isPending:
if (isPending) {
return <ProductListSkeleton />;
}
Até a versão 4 do TanStack Query, isLoading indicava o estado inicial da query. Na versão 5, o antigo status loading foi renomeado para pending, e o antigo isLoading passou a se chamar isPending.
O isLoading continua existindo na versão 5, mas agora é um estado derivado de isPending && isFetching. Na prática, ele indica que a query está pendente e que a primeira requisição está em andamento.
Já o isFetching indica que uma requisição está sendo executada, inclusive quando já existem dados disponíveis no cache:
return (
<div>
{isFetching && <small>Atualizando...</small>}
<ProductTable products={products} />
</div>
);
A diferença conceitual é:
- isPending → A query ainda não possui dados disponíveis.
- isLoading → A query está pendente e a primeira requisição está em andamento.
- isFetching → Uma requisição está em andamento, independentemente de já existirem dados no cache.
Não é recomendável substituir toda a interface por um spinner sempre que isFetching for true, pois isso eliminaria uma das principais vantagens do cache.
Uma experiência melhor é manter os dados visíveis e mostrar apenas um indicador discreto:
export function ProductList() {
const {
data = [],
isPending,
isFetching,
error,
} = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 30_000,
});
if (isPending) {
return <ProductListSkeleton />;
}
if (error) {
return <ErrorMessage message={error.message} />;
}
return (
<section>
<header>
<h1>Produtos</h1>
{isFetching && (
<span aria-live="polite">
Sincronizando…
</span>
)}
</header>
<ProductTable products={data} />
</section>
);
}
Sincronizando os dados do cache
O cache fica mais complexo quando o usuário modifica dados.
Considere uma mutation que altera o preço de um produto.
A mutation pode atualizar corretamente o servidor, mas a lista armazenada no cache ["products"] continuará contendo os dados antigos.
O frontend precisa decidir como sincronizar o cache. Existem três estratégias principais para isso.
Estratégia 1: invalidar a query
A opção mais simples e segura geralmente é invalidar as queries relacionadas no onSuccess da mutation.
import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["products"],
});
},
});
}
Quando a mutation termina, o servidor salva a alteração, o React Query marca ["products"] como stale, as queries ativas podem executar uma nova busca, e a interface recebe os dados atualizados do servidor.
Vantagens
- Implementação simples;
- Utiliza o servidor como fonte da verdade;
- Reduz o risco de montar incorretamente o novo estado;
- Funciona bem quando várias informações podem ter sido afetadas.
Desvantagens
- Cria uma requisição adicional;
- A atualização visual pode demorar um pouco mais;
- Pode ser excessivo para alterações muito pequenas.
Quando usar
- A correção é mais importante que evitar uma requisição;
- O servidor executa cálculos ou regras adicionais;
- Várias queries podem ter sido afetadas;
- A lógica para alterar o cache seria complexa.
Estratégia 2: Atualizar o Cache Diretamente
Quando a API retorna o produto atualizado na esposta de uma requisição de atualização, podemos substituir esse produto diretamente no cache do React Query:
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
onSuccess: (updatedProduct) => {
// Atualizamos o cache do produto individual ["product", updatedProduct.id]
queryClient.setQueryData(
["product", updatedProduct.id],
updatedProduct,
);
// Atualizamos o cache dos produtos ["products"]
queryClient.setQueryData<Product[]>(
["products"],
(products) =>
products?.map((product) =>
product.id === updatedProduct.id
? updatedProduct
: product,
) ?? [],
);
},
});
}
Vantagens
- Não exige uma nova requisição;
- A interface é atualizada imediatamente após a resposta;
- Aproveita os dados retornados pela API.
Desvantagens
- É necessário conhecer todas as entradas afetadas;
- Filtros e paginação tornam a atualização mais complexa;
- É possível deixar diferentes caches inconsistentes;
Quando Usar
- A resposta da API contém o recurso completo;
- As entradas afetadas são conhecidas;
- Você consegue atualizar o cache de forma confiável.
Estratégia 3: Atualização otimista (optimistic update)
Uma atualização otimista modifica a interface antes de o servidor confirmar a operação. A aplicação assume temporariamente que a mutation será bem-sucedida. Caso ocorra algum erro na requisição, o valor é revertido.
export function useUpdateProductPrice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
onMutate: async (updatedProduct) => {
await queryClient.cancelQueries({
queryKey: ["products"],
});
const previousProducts =
queryClient.getQueryData<Product[]>(["products"]);
queryClient.setQueryData<Product[]>(
["products"],
(currentProducts) =>
currentProducts?.map((product) =>
product.id === updatedProduct.id
? {
...product,
price: updatedProduct.price,
}
: product,
) ?? [],
);
return {
previousProducts,
};
},
onError: (_error, _variables, context) => {
if (context?.previousProducts) {
queryClient.setQueryData(
["products"],
context.previousProducts,
);
}
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["products"],
});
},
});
}
Vantagens
- Resposta visual praticamente instantânea;
- Melhora interações frequentes;
- Funciona bem para ações simples e previsíveis, como curtir uma publicação, marcar uma tarefa, favoritar um item, reorganizar uma lista.
Desvantagens
- Rollback mais complexo;
- Concorrência pode produzir conflitos;
- Regras de negócio podem impedir a alteração;
- Não é ideal quando falhas são frequentes ou muito importantes.
Quando Usar
- A interação precisa parecer instantânea;
- A operação costuma funcionar;
- O rollback é simples;
- O impacto de uma falha temporária é pequeno.
Quando Não Usar
- Para lidar com pagamentos.
- Em alterações críticas.
Outras estratégias: refetch, polling, prefetching
React Query também disponibiliza outras maneiras de realizar uma requisição e atualizar o cache em alguns cenários que podem ser úteis, como refetch manual, polling e prefetching.
Refetch manual
O hook useQuery retorna um método chamado refetch que podemos chamar para buscar e atualizar o cache imediatamente.
export function ProductList() {
const {
data = [],
isPending,
isFetching,
refetch,
} = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 30_000,
});
if (isPending) {
return <ProductListSkeleton />;
}
return (
<section>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Atualizando..." : "Atualizar"}
</button>
<ProductTable products={data} />
</section>
);
}
Polling
Veja sua documentação aqui.
Para informações que precisam de atualização frequente, podemos executar requisições em intervalos:
useQuery({
queryKey: ["order-status", orderId],
queryFn: () => fetchOrderStatus(orderId),
refetchInterval: 10_000,
});
Nesse exemplo, o status do pedido é atualizado a cada dez segundos.
Polling é simples, mas possui custos:
- Aumenta o número de requisições;
- Pode buscar dados mesmo quando nada mudou;
- Consome recursos do servidor;
- Pode ser inadequado em grande escala.
Dependendo do caso, WebSockets ou Server-Sent Events podem ser mais eficientes.
Prefetching
Veja sua documentação aqui.
O cache também pode ser preenchido antes de o usuário abrir uma página.
import { useQueryClient } from "@tanstack/react-query";
export function ProductLink({
productId,
children,
}: {
productId: number;
children: React.ReactNode;
}) {
const queryClient = useQueryClient();
function prefetchProduct() {
queryClient.prefetchQuery({
queryKey: ["product", productId],
queryFn: () => fetchProduct(productId),
staleTime: 30_000,
});
}
return (
<a
href={`/products/${productId}`}
onMouseEnter={prefetchProduct}
onFocus={prefetchProduct}
>
{children}
</a>
);
}
Ao passar o mouse ou focar no link, a aplicação busca os detalhes, o resultado é armazenado no cache e, quando o usuário abre a página, os dados podem já estar disponíveis.
Prefetching melhora a velocidade percebida, mas deve ser utilizado com cuidado para não carregar informações que provavelmente não serão usadas.
Conclusão
Cache no frontend vai muito além de evitar requisições repetidas. Ele ajuda a equilibrar diferentes necessidades da aplicação, como:
- Performance.
- Velocidade percebida.
- Consumo de rede.
- Atualização dos dados.
- Consistência.
O React Query facilita esse processo ao oferecer recursos para controlar o ciclo de vida dos dados vindos do servidor, incluindo armazenamento em cache, revalidação, invalidação e sincronização após alterações.
No entanto, usar cache não significa manter todas as informações armazenadas indefinidamente. Quanto mais tempo os dados permanecem no cache, maior pode ser o risco de apresentar informações desatualizadas.
Além disso, o cache também possui custos:
- Consumo de memória;
- Complexidade de invalidação;
- Possíveis inconsistências entre cliente e servidor;
- Maior dificuldade de depuração;
- Necessidade de definir estratégias de atualização.
Por isso, a configuração do cache não deve ser apenas uma decisão técnica. Ela também deve considerar as necessidades do produto e o impacto que informações desatualizadas podem causar ao usuário.
Quanto maior o risco de apresentar uma informação incorreta, menor deve ser o período em que a aplicação confia apenas nos dados do cache.
A melhor estratégia não é armazenar tudo pelo maior tempo possível, mas responder conscientemente à seguinte pergunta:
Por quanto tempo esses dados podem permanecer desatualizados sem prejudicar o usuário ou o funcionamento da aplicação?
