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:

  1. React Query procura ["products"] no cache.
  2. Como os dados ainda não existem, executa fetchProducts.
  3. O componente entra no estado de carregamento inicial.
  4. A API responde.
  5. Os dados são armazenados no cache.
  6. 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?