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.
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-revalidatefuncionando. 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:
| Gatilho | Padrão | Dispara quando |
|---|---|---|
refetchOnMount | true | Uma nova instância monta e a query está stale |
refetchOnWindowFocus | true | A janela recebe foco e a query está stale |
refetchOnReconnect | true | A 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 dado | staleTime | Motivo |
|---|---|---|
| Cotação, saldo, estoque | 0 | Errar aqui custa dinheiro |
| Lista alimentada por outras pessoas | 30s a 1min | Precisa acompanhar, mas não a cada foco |
| Perfil de quem está logado | 5min | Muda pouco, e a própria pessoa dispara a mudança |
| Lista de categorias, países, moedas | 1h | Praticamente estático |
| Permissões carregadas no login | Infinity 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:
gcTimemenor questaleTime: você declarou que o dado é confiável por dez minutos e manda descartá-lo da memória em um. Na prática ostaleTimegrande deixa de ter efeito entre navegações.gcTime: Infinitycomo 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:
cancelQueriesvem 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.previousé retornado deonMutate, chega emcontextnoonErrore torna o rollback possível. Sem esse snapshot, o erro deixa a UI mostrando uma mentira.onSettledreconcilia 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
| Situação | Escolha | Por quê |
|---|---|---|
| A escrita mexe em várias visões, ou muda ordenação e paginação | invalidateQueries | Só o servidor sabe o resultado final |
| A resposta da mutação é o recurso completo | setQueryData no detalhe, invalidateQueries nas listas | Não pede de volta o que já veio |
| Ação frequente, de baixo risco, com latência visível | Update otimista | A percepção de velocidade compensa o código extra |
| O resultado otimista aparece em um lugar só | variables do useMutation | Mesmo efeito com uma fração do código |
| Você já escreveu o valor certo na mão | invalidateQueries 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.