Pular para o conteúdo
Voltar para o blog

Cache no TanStack Query: o que cada API resolve

staleTime, gcTime, invalidateQueries, setQueryData e update otimista. O que cada peça faz de fato, e o cenário em que ela é a escolha certa.

13 min de leitura
  • TanStack Query
  • Cache
  • React
Representação abstrata do ciclo de vida do cache

Como guardar na tela um dado que veio do servidor? Quase todo projeto React começa pela resposta mais direta: um useState preenchido dentro de um useEffect.

const [tasks, setTasks] = useState<Task[] | null>(null);
const [loading, setLoading] = useState(true);
 
useEffect(() => {
  fetchTasks().then((data) => {
    setTasks(data);
    setLoading(false);
  });
}, []);

Esse trecho funciona até a segunda tela precisar da mesma lista. Aí você duplica a requisição, ou sobe o estado para um contexto, e sem perceber começou a escrever um cache. Falta deduplicação de requisições simultâneas, revalidação, invalidação depois de uma escrita, retry, cancelamento, e uma resposta para o caso em que a requisição antiga chega depois da nova.

O TanStack Query parte de uma distinção que resolve a maior parte disso: estado de servidor não é estado de cliente. Estado de cliente você é dono: um modal aberto, um campo sendo digitado, um passo de wizard. Estado de servidor você não é. Existe uma fonte da verdade em outro lugar, ela muda sem te avisar, e o que está na sua tela é uma cópia possivelmente desatualizada. A biblioteca trata essa cópia pelo que ela é, um cache, e te dá controle explícito sobre quando ela expira, quando ela é descartada e quem manda nela.

O preço é entender as peças. Elas são poucas, mas fazem coisas parecidas o suficiente para serem trocadas uma pela outra por engano. Query key, staleTime, gcTime, invalidateQueries, setQueryData e update otimista: cada seção abaixo pega uma delas e o cenário em que ela é a escolha certa. Os exemplos são da v5 com React.

Query key: o endereço da entrada no cache

Antes de qualquer configuração de tempo, é a query key que decide o que é cache hit e o que é uma entrada nova. Ela é o identificador da entrada, não um rótulo descritivo.

const tasksQuery = useQuery({
  queryKey: ["tasks", { status, page }],
  queryFn: () => fetchTasks({ status, page }),
});

O guia de query keys documenta três comportamentos que decidem o desenho da query key.

As query keys são hasheadas de forma determinística. Ordem de propriedade dentro de um objeto não importa: { status, page } e { page, status } acessam a mesma entrada. Ordem de item no array importa, e ["tasks", status, page] é uma entrada diferente de ["tasks", page, status].

Toda variável usada dentro da queryFn precisa estar na query key. Ela funciona como o array de dependências do useEffect: quando page muda e não está na query key, o TanStack Query continua entregando a entrada antiga porque, do ponto de vista dele, nada mudou.

A hierarquia da query key define o alcance da invalidação. Isso só cobra o preço mais tarde, na hora de invalidar, e é por isso que ela merece ser desenhada do geral para o específico:

["tasks"]
["tasks", "list", { status: "done" }]
["tasks", "detail", taskId]

Com esse formato, ["tasks"] alcança tudo, ["tasks", "list"] alcança só as listagens, e o detalhe de uma task individual pode ser atingido sozinho. Com query keys planas do tipo ["tasksList"] e ["taskDetail", id] você perde essa capacidade e passa a invalidar na mão, uma por uma.

Para não repetir string solta pelo código, o padrão comum é centralizar as query keys em um objeto:

export const taskKeys = {
  all: ["tasks"] as const,
  lists: () => [...taskKeys.all, "list"] as const,
  list: (filters: TaskFilters) => [...taskKeys.lists(), filters] as const,
  details: () => [...taskKeys.all, "detail"] as const,
  detail: (id: string) => [...taskKeys.details(), id] as const,
};

Os dois relógios: staleTime e gcTime

Toda entrada no cache é governada por dois temporizadores independentes que respondem a perguntas diferentes.

staleTime responde por quanto tempo esse dado pode ser considerado fresco. Enquanto a query está fresca, o TanStack Query devolve o valor em memória e não vai à rede, nem em remontagem nem em foco de janela.

gcTime responde por quanto tempo esse dado é guardado depois que ninguém mais está usando. Ele só começa a contar quando o último observer daquela query desmonta.

Os dois se combinam com dois eixos de estado. Uma query é fresh ou stale (o relógio do staleTime), e é active ou inactive conforme exista ou não algum componente montado observando aquela query key.

Uma entrada nasce fresh, vira stale ao fim do staleTime, vira inactive quando o último observer desmonta, e é removida ao fim do gcTime

Os padrões da biblioteca são staleTime: 0 e gcTime: 5 * 60 * 1000. Ou seja: todo dado nasce stale, mas fica na memória por cinco minutos. É esse par que produz o comportamento que confunde quem chega. A tela mostra o dado antigo imediatamente e dispara um refetch em segundo plano.

Isso é o stale-while-revalidate funcionando. Mostrar algo instantaneamente e corrigir depois é quase sempre melhor do que mostrar um spinner sobre um dado que você já tem.

A diferença de escopo entre os dois cobra caro quando passa despercebida: staleTime é por hook, gcTime é por entrada no cache. Se dois componentes observam a mesma query key com staleTime diferente, cada um decide sozinho se precisa refetchar; o gcTime que vale para a entrada é o maior entre eles.

staleTime: por quanto tempo o dado pode estar errado

staleTime não controla se o dado fica na memória. Controla se ele é confiável o bastante para ser entregue sem checar o servidor. É ele que decide o comportamento dos gatilhos que vêm ligados por padrão:

GatilhoPadrãoDispara quando
refetchOnMounttrueUma nova instância monta e a query está stale
refetchOnWindowFocustrueA janela recebe foco e a query está stale
refetchOnReconnecttrueA rede volta e a query está stale

Nenhum dos três dispara sobre dado fresco. Por isso desligar refetchOnWindowFocus porque "tem requisição demais" costuma ser tratar o sintoma: com staleTime: 0, todo alt-tab é um refetch legítimo, já que você declarou que o dado nasce stale. Ajustar o staleTime resolve o mesmo problema sem desligar a revalidação nos momentos em que ela é útil.

Na hora de escolher o valor, a pergunta que importa é quão ruim é mostrar esse dado desatualizado por alguns segundos.

Tipo de dadostaleTimeMotivo
Cotação, saldo, estoque0Errar aqui custa dinheiro
Lista alimentada por outras pessoas30s a 1minPrecisa acompanhar, mas não a cada foco
Perfil de quem está logado5minMuda pouco, e a própria pessoa dispara a mudança
Lista de categorias, países, moedas1hPraticamente estático
Permissões carregadas no loginInfinity ou 'static'Não muda enquanto a sessão existe

Entre Infinity e 'static' existe uma diferença de comportamento que não aparece no nome: invalidateQueries ainda funciona em uma query com staleTime: Infinity e não tem efeito nenhum em uma com staleTime: 'static'. Use Infinity para dado que raramente muda mas que você quer poder forçar a atualizar por evento; use 'static' para dado que é imutável durante a vida da aplicação, como feature flags lidas no boot.

const flagsQuery = useQuery({
  queryKey: ["feature-flags"],
  queryFn: fetchFeatureFlags,
  staleTime: "static",
});

staleTime também aceita uma função que recebe a query, o que permite derivar o tempo da própria resposta em vez de chutar um número:

const tokenQuery = useQuery({
  queryKey: ["session-token"],
  queryFn: fetchSessionToken,
  staleTime: (query) => query.state.data?.expiresInMs ?? 0,
});

gcTime: por quanto tempo o dado sobrevive sem observers

gcTime é o relógio que quase nunca precisa ser ajustado, e por isso mesmo é o que mais gera diagnóstico errado quando é ajustado sem necessidade.

Ele não tem efeito nenhum enquanto existe alguém observando a query. Só quando o último observer desmonta é que a entrada entra em inactive e o cronômetro começa. Se ninguém voltar a usar aquela query key dentro do prazo, a entrada é removida da memória, e a próxima montagem é um carregamento do zero, com spinner e tudo.

O cenário em que ele importa de verdade é navegação de ida e volta:

const taskQuery = useQuery({
  queryKey: taskKeys.detail(taskId),
  queryFn: () => fetchTask(taskId),
  staleTime: 30 * 1000, // 30 segundos
  gcTime: 10 * 60 * 1000, // 10 minutos
});

Quem abre um detalhe, volta para a lista e abre outro detalhe está desmontando e remontando essas queries o tempo todo. Com gcTime generoso, voltar para um item já visitado mostra o conteúdo na hora e revalida atrás; com gcTime curto, cada volta é uma tela em branco.

Duas configurações que denunciam confusão entre os dois relógios:

  • gcTime menor que staleTime: você declarou que o dado é confiável por dez minutos e manda descartá-lo da memória em um. Na prática o staleTime grande deixa de ter efeito entre navegações.
  • gcTime: Infinity como solução para "o dado some": se o dado some enquanto a tela está aberta, o problema está em outro lugar, porque uma query com observer ativo nunca é coletada. Olhe a query key primeiro. Quase sempre ela mudou sem você perceber.

invalidateQueries: marcar como stale e deixar a rede resolver

Depois de uma escrita bem-sucedida, o cache está mentindo. invalidateQueries é a forma de admitir isso: marca as entradas atingidas como stale e dispara refetch para as que estão sendo observadas.

const queryClient = useQueryClient();
 
const createTask = useMutation({
  mutationFn: postTask,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
  },
});

É o caminho seguro, porque o servidor continua sendo a fonte da verdade. Você não tenta adivinhar como ficou a lista depois da escrita (ordenação, paginação, campos calculados, permissões), você pergunta de novo.

A correspondência é por prefixo, e isso muda o resultado mais do que parece. ["tasks"] atinge ["tasks", "list", { status: "done" }] e ["tasks", "detail", 42] junto. É exatamente o que você quer quando a mutação mexe em várias visões do mesmo recurso, e é uma chuva de requisições quando você só queria atingir uma lista.

Para restringir, existem dois mecanismos. exact: true desliga a correspondência por prefixo, então ele só faz sentido apontado para uma chave que existe de verdade: em taskKeys.all não atingiria nada, porque nenhuma query usa ["tasks"] sozinha, ela só existe como prefixo.

O segundo é o predicate, para quando o critério não cabe em uma chave. Ele é uma função que recebe cada query candidata inteira e devolve true para as que devem ser invalidadas, o que libera filtros que a correspondência por prefixo não expressa: olhar posições específicas da query key, inspecionar o objeto de filtros dentro dela, ou levar em conta o estado da própria query. Ele é o último filtro aplicado, então dá para combinar com queryKey e os dois precisam bater.

queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId), exact: true });
 
queryClient.invalidateQueries({
  // atinge qualquer ["tasks", "list", ...] e ignora os detalhes
  predicate: (query) =>
    query.queryKey[0] === "tasks" && query.queryKey[1] === "list",
});

E existe uma terceira opção que separa "marcar como stale" de "buscar agora": refetchType, que aceita 'active' | 'inactive' | 'all' | 'none' e tem 'active' como padrão.

Esse padrão significa que queries inativas, como a lista de outra aba ou o detalhe de um item que você já fechou, são marcadas como stale mas não vão à rede naquele momento. Elas revalidam sozinhas na próxima vez que forem montadas. É o comportamento certo na maioria dos casos, e saber disso poupa uma hora de caçada atrás de por que "a invalidação não refetchou".

queryClient.invalidateQueries({ queryKey: taskKeys.all, refetchType: "none" });

Esse refetchType: "none" é útil quando você já escreveu o valor novo no cache na mão e só quer que as entradas sejam consideradas stale para a próxima visita, sem pagar requisição agora.

setQueryData: escrever direto, sem ida à rede

setQueryData escreve o valor novo direto na entrada do cache. Zero requisição, atualização síncrona na tela.

O uso mais justificável é quando a resposta da mutação já é o recurso completo. Nesse caso, invalidar seria pedir ao servidor um dado que ele acabou de te mandar:

const updateTask = useMutation({
  mutationFn: putTask,
  onSuccess: (updated) => {
    queryClient.setQueryData(taskKeys.detail(updated.id), updated);
    queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
  },
});

Repare no par: o detalhe é escrito direto porque a resposta é exatamente aquele recurso; as listas são invalidadas porque a posição do item dentro delas depende de ordenação e filtro, coisa que só o servidor sabe.

Regras que a documentação deixa explícitas e que mesmo assim costumam ser descobertas na marra:

A atualização precisa ser imutável. Mutar o objeto recebido não dispara re-render e ainda corrompe a entrada.

queryClient.setQueryData<Task[]>(taskKeys.list(filters), (old) =>
  // devolve uma lista nova trocando só o item alterado
  old?.map((task) => (task.id === updated.id ? updated : task))
);

Retornar undefined cancela a escrita. É uma proteção: se a entrada ainda não existe (old é undefined), o ?. acima devolve undefined e nada é escrito, então você não cria uma lista pela metade a partir de uma mutação isolada.

A escrita não valida nada. É sua a responsabilidade de colocar no cache exatamente a forma que a queryFn produziria. Se o endpoint da mutação devolve um objeto ligeiramente diferente do endpoint de leitura, com um campo a menos ou uma relação não expandida, você acabou de plantar um dado inconsistente que só vai explodir na tela que usa aquele campo.

O outro cenário forte para setQueryData é semear o cache com dado que você já tem em mãos. Se a listagem já traz o objeto completo, dá para preencher o detalhe antes mesmo da navegação:

function TaskRow({ task }: { task: Task }) {
  const queryClient = useQueryClient();
 
  return (
    <Link
      href={`/tasks/${task.id}`}
      onMouseEnter={() => {
        // semeia o detalhe com o objeto da listagem, já marcado como stale
        queryClient.setQueryData(taskKeys.detail(task.id), task, {
          updatedAt: 0,
        });
      }}
    >
      {task.title}
    </Link>
  );
}

A tela de detalhe abre com conteúdo em vez de skeleton. Repare no terceiro argumento: setQueryData carimba a entrada com o horário da escrita, então o dado semeado nasce fresco pelo staleTime daquela query, não stale. Com o staleTime: 30 * 1000 do exemplo anterior, o objeto que veio da listagem seria servido como verdade por meio minuto. Zerar o updatedAt faz a entrada nascer stale e revalidar em segundo plano sozinha, que é o que você quer quando a listagem traz uma versão mais pobre do recurso.

Update otimista: o cache como estado da UI

Quando a latência é visível e a operação quase sempre dá certo (marcar uma task como feita, favoritar, curtir), dá para escrever no cache antes da confirmação do servidor e desfazer se der errado.

const toggleTask = useMutation({
  mutationFn: patchTask,
  onMutate: async (task: Task) => {
    // impede que um refetch em voo pouse depois e desfaça a escrita otimista
    await queryClient.cancelQueries({ queryKey: taskKeys.lists() });
 
    // snapshot do estado atual, usado para desfazer se a mutação falhar
    const previous = queryClient.getQueryData<Task[]>(taskKeys.list(filters));
 
    queryClient.setQueryData<Task[]>(taskKeys.list(filters), (old) =>
      old?.map((item) =>
        item.id === task.id ? { ...item, done: !item.done } : item
      )
    );
 
    return { previous };
  },
  onError: (_error, _task, context) => {
    if (context?.previous) {
      queryClient.setQueryData(taskKeys.list(filters), context.previous);
    }
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
  },
});

O que sustenta esse trecho:

  1. cancelQueries vem primeiro. Sem ele, um refetch que já estava em voo pode pousar depois da sua escrita otimista e desfazer a mudança na tela. É o bug clássico que só aparece em conexão lenta e é péssimo de reproduzir.
  2. previous é retornado de onMutate, chega em context no onError e torna o rollback possível. Sem esse snapshot, o erro deixa a UI mostrando uma mentira.
  3. onSettled reconcilia em sucesso ou em erro. A última palavra é sempre do servidor.

Antes de escrever tudo isso, cheque se você precisa mesmo. A própria documentação recomenda o caminho mais curto quando o resultado otimista aparece em um único lugar: ler variables do próprio useMutation e renderizar o item pendente ali, sem tocar no cache.

const queryClient = useQueryClient();
 
const createTask = useMutation({
  mutationFn: postTask,
  onSettled: () => queryClient.invalidateQueries({ queryKey: taskKeys.lists() }),
});
 
return (
  <ul>
    {tasks.map((task) => (
      <TaskRow key={task.id} task={task} />
    ))}
    {createTask.isPending && <TaskRow task={createTask.variables} pending />}
  </ul>
);

Menos código, nada de snapshot e rollback, e o placeholder some sozinho quando a mutação termina. O onSettled não é opcional aqui: isPending vira false no instante do sucesso, e sem a invalidação a lista fica sem o item novo até algum outro gatilho revalidar. Escrever no cache só se paga quando o resultado otimista precisa aparecer em vários lugares da árvore ao mesmo tempo.

Escolhendo depois de uma mutação

invalidateQueries, setQueryData e refetchType 'none' comparados por quando usar e quanto custam
SituaçãoEscolhaPor quê
A escrita mexe em várias visões, ou muda ordenação e paginaçãoinvalidateQueriesSó o servidor sabe o resultado final
A resposta da mutação é o recurso completosetQueryData no detalhe, invalidateQueries nas listasNão pede de volta o que já veio
Ação frequente, de baixo risco, com latência visívelUpdate otimistaA percepção de velocidade compensa o código extra
O resultado otimista aparece em um lugar sóvariables do useMutationMesmo efeito com uma fração do código
Você já escreveu o valor certo na mãoinvalidateQueries com refetchType: "none"Marca como stale sem pagar requisição agora

O que ajustar no QueryClient

Configurar aqui evita repetir a mesma opção em dezenas de hooks:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minuto
      gcTime: 10 * 60 * 1000, // 10 minutos
      retry: 2,
    },
  },
});

staleTime global maior que zero é a mudança de maior impacto na maioria dos projetos: corta a enxurrada de refetch em cada montagem e cada foco de janela, e ainda deixa cada query subir o valor quando o dado dela é mais estável. O padrão de retry é 3 tentativas com backoff exponencial, o que em erro de validação só serve para atrasar a mensagem de erro em alguns segundos. Reduza, ou condicione ao status da resposta.

O que não deve entrar aqui é refetchOnWindowFocus: false como reflexo. Ele desliga uma das melhores propriedades da biblioteca. Se o refetch a cada alt-tab incomoda, o número errado quase sempre é o staleTime.

Onde aprofundar

A documentação oficial do TanStack Query é curta o suficiente para ler inteira. Os guias de query keys, invalidação e updates otimistas cobrem o que foi resumido aqui, e a referência do QueryClient é onde ficam as opções que ninguém lembra de cor.

Fora da documentação, o blog do TkDodo, mantenedor da biblioteca, explica as decisões de design por trás dessas APIs. Effective React Query Keys e Automatic Query Invalidation after Mutations são os dois mais úteis para o que ficou aqui.

Ficou de fora, e é o próximo passo: queryOptions para compartilhar query key e fetcher entre prefetch e hook, placeholderData e initialData, e hidratação do cache em Server Components.