Ir para o conteúdo

Query (TanStack Query)

Wrappers finos pra padronizar tempos de cache, chaves de query e o QueryClient. Você continua usando o @tanstack/react-query de sempre — o SDK só entrega os defaults bem calibrados e um factory de keys tipado.

Por que esses wrappers existem?

Sem padronização, cada tela escolhe um staleTime no chute e cada domínio escreve queryKey: ["user", id] à mão. Isso gera invalidações que não pegam (key montada diferente em dois lugares) e refetch agressivo demais. O SDK centraliza ambos: presets nomeados e um factory que garante a mesma key em todo lugar.

Provider

Envolva a árvore do app uma única vez, normalmente no main.tsx (ou dentro de <AppProviders>):

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryProvider } from "tempest-react-sdk";
import "tempest-react-sdk/styles.css";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryProvider>
      <App />
    </QueryProvider>
  </StrictMode>,
);

Defaults aplicados quando você não passa um client:

  • staleTime: 5 min (STALE_TIME.DEFAULT)
  • gcTime: 30 min (CACHE_TIME.DEFAULT)
  • retry: shouldRetryQuery (queries) / 0 (mutations)
  • refetchOnWindowFocus: false

4xx não é retentado — e isso mudou

O default era retry: 1 chapado, que replicava um 403 numa listagem admin-only e um 404 de registro apagado. O servidor recusou de propósito nos dois casos: a segunda tentativa devolve a mesma resposta, dobra o log de rede e segura o spinner na tela por mais um round trip.

shouldRetryQuery retenta uma vez apenas o que pode mudar sozinho: falha de rede (status === 0), 5xx, 408, 425 e 429 (que é uma recusa cujo significado literal é "mais tarde"), e erro de formato desconhecido — que pode ser falha de transporte. Todo o resto do 4xx falha de primeira.

A classificação vem de isRetriableStatus, a mesma que createApiClient({ retry: true }) e o helper retry() usam. Até a v0.44.0 era uma cópia própria aqui, sem o 425 — então o mesmo 425 Too Early era retentado pelo cliente e não pela query.

Se o seu app dependia de retry em 4xx, o override é o de sempre: defaultOptions={{ queries: { retry: 1 } }}.

Para sobrescrever, passe defaultOptions (mesclado por cima dos defaults) ou um client pronto (ignora os defaults do SDK):

import { QueryClient } from "@tanstack/react-query";
import { QueryProvider, STALE_TIME } from "tempest-react-sdk";

// Opção A — só ajustar alguns defaults
<QueryProvider defaultOptions={{ queries: { staleTime: STALE_TIME.LONG } }}>
  <App />
</QueryProvider>;

// Opção B — trazer seu próprio client (compartilhar entre roots, plugar devtools, etc.)
const client = new QueryClient();
<QueryProvider client={client}>
  <App />
</QueryProvider>;

Um único QueryClient por app

Não aninhe dois QueryProvider sem passar o mesmo client. Cada provider cria um cache isolado, e queries de subárvores diferentes deixam de compartilhar dados. Para múltiplos roots, crie o QueryClient uma vez e passe via prop client.

\"No QueryClient set\" com o provider montado = duas cópias do react-query

O client que você passa é o único ponto onde a cópia do app e a do SDK se encostam. Se o npm aninhou uma segunda cópia de @tanstack/react-query dentro do SDK, o QueryClientProvider publica o seu client no contexto daquela cópia — e todo useQuery do app lê o contexto da outra, não acha nada, e lança:

No QueryClient set, use QueryClientProvider to set one

Com o provider visivelmente montado três linhas acima. Nada nessa mensagem aponta pra duplicata, e é por isso que ela custa uma tarde.

A partir da v0.52.1 o SDK detecta e avisa em desenvolvimento: um client de outra cópia faz duck-type perfeito mas falha o instanceof, que é a discriminação exata. O conserto é npm dedupe, e npx tempest doctor lista toda dependência duplicada.

Presets de tempo

import { useQuery } from "@tanstack/react-query";
import { STALE_TIME, CACHE_TIME, REFETCH_TIME } from "tempest-react-sdk";

useQuery({
  queryKey: ["dashboard"],
  queryFn: fetchDashboard,
  staleTime: STALE_TIME.LONG, // 30 min — dado muda pouco
  gcTime: CACHE_TIME.LONG, // 1 h
  refetchInterval: REFETCH_TIME.FAST, // 30 s — polling de status
});
  • STALE_TIME: SHORT 30s, DEFAULT 5min, LONG 30min, INFINITE
  • CACHE_TIME: SHORT 5min, DEFAULT 30min, LONG 1h
  • REFETCH_TIME: REALTIME 5s, FAST 30s, DEFAULT 60s, SLOW 5min

Quando usar INFINITE

STALE_TIME.INFINITE marca o dado como nunca obsoleto — o TanStack só refaz o fetch via invalidação manual. Ideal para listas estáticas (categorias, cidades) que mudam por deploy, não por uso.

Query keys tipadas

createQueryKeys recebe um scope (prefixo do domínio) e um mapa de builders. Cada saída já vem com o scope na frente, então a mesma key é montada de forma idêntica em toda a base de código:

import { createQueryKeys } from "tempest-react-sdk";

export const userKeys = createQueryKeys("user", {
  me: () => ["me"] as const,
  byId: (id: string) => [id] as const,
  list: (filters: { page: number; size: number }) => ["list", filters] as const,
});

userKeys.all; // ["user"]
userKeys.me(); // ["user", "me"]
userKeys.byId("42"); // ["user", "42"]
userKeys.list({ page: 1, size: 20 }); // ["user", "list", { page: 1, size: 20 }]

Note o all: ele é gerado automaticamente e é a key mais ampla do domínio — invalidá-lo derruba todas as queries user/* de uma vez.

Exemplo completo — query + mutation + invalidação

Esse componente lê o perfil com useQuery, atualiza com useMutation e invalida só as keys afetadas no sucesso:

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { createApiClient, createQueryKeys } from "tempest-react-sdk";

interface User {
  id: string;
  name: string;
}

const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL });

export const userKeys = createQueryKeys("user", {
  me: () => ["me"] as const,
  byId: (id: string) => [id] as const,
});

export function ProfileCard() {
  const queryClient = useQueryClient();

  const { data: user, isLoading } = useQuery({
    queryKey: userKeys.me(),
    queryFn: () => api.get<User>("/users/me"),
  });

  const rename = useMutation({
    mutationFn: (name: string) => api.patch<User>("/users/me", { body: { name } }),
    onSuccess: (updated) => {
      // Atualiza o cache local sem novo fetch...
      queryClient.setQueryData(userKeys.me(), updated);
      // ...e invalida o registro por id, caso outra tela o use.
      queryClient.invalidateQueries({ queryKey: userKeys.byId(updated.id) });
    },
  });

  if (isLoading) return <p>Carregando</p>;

  return (
    <div>
      <h2>{user?.name}</h2>
      <button disabled={rename.isPending} onClick={() => rename.mutate("Novo nome")}>
        Renomear
      </button>
    </div>
  );
}

Por que setQueryData + invalidateQueries?

setQueryData aplica a resposta da mutation no cache imediatamente (UI sem flicker), enquanto invalidateQueries marca queries relacionadas como obsoletas pra revalidar no background. Usar uma key factory garante que a key invalidada seja exatamente a mesma que a query consultou.

Padrão de organização: cada domínio em src/constants/query-keys/<dominio>.ts, agrupados num barrel.

useOfflineMutation

Quando o app é offline-first, uma mutation não deve bater na rede direto — ela grava no outbox do createOfflineSync e sincroniza depois. useOfflineMutation faz a ponte entre o motor de sync e o TanStack Query: no mutate ele enfileira a entrada, atualiza o cache da query otimisticamente, dá o flush e faz rollback do cache se o enqueue falhar.

import { useOfflineMutation } from "tempest-react-sdk";
import { notesSync } from "@/sync/engine";
import type { Note } from "@/sync/types";

function useAddNote() {
  return useOfflineMutation<Note, Note[], Note>({
    sync: notesSync,
    queryKey: ["notes"],
    toEntry: (note) => ({ op: "create", recordId: note.id, payload: note }),
    applyOptimistic: (current = [], note) => [...current, note],
  });
}

// const addNote = useAddNote();
// addNote.mutate({ id: crypto.randomUUID(), text: "offline!" });
  • toEntry mapeia as variáveis pra { op, recordId, payload } do outbox.
  • applyOptimistic produz o próximo valor do cache; o anterior é restaurado se o enqueue lançar.
  • flush (default true"after-mutation") dispara a sincronização; false deixa isso pro useOfflineSync.
  • invalidate (default false) revalida a queryKey no onSettled.

Helpers de cache de lista

Para o caso comum (cache é uma lista), use upsertById() / removeById() no lugar de escrever o spread na mão:

import { upsertById, removeById } from "tempest-react-sdk";

applyOptimistic: upsertById(); // insere ou faz merge por `id`
applyOptimistic: removeById(); // remove por `id` (op "delete")
// campo custom: upsertById("uuid")

A entrega ao servidor acontece no flush

O mutate resolve com o id da entrada no outbox, não com a resposta do servidor — a entrega real roda no loop de flush do motor, então a UI atualiza na hora e sobrevive a reloads e a períodos offline.

persistQueryClientOffline

Persiste o cache do QueryClient no IndexedDB e restaura no boot — um reload ou início offline mostra os últimos dados conhecidos em vez de telas vazias. Self-contained: usa dehydrate/hydrate do próprio @tanstack/react-query, sem precisar do @tanstack/react-query-persist-client.

import { persistQueryClientOffline } from "tempest-react-sdk";
import { queryClient } from "@/lib/query";

const persistence = persistQueryClientOffline({ queryClient });
await persistence.restore(); // antes do primeiro render

// no logout:
await persistence.clear();
// no teardown:
persistence.unsubscribe();

As gravações são throttled (throttleMs, default 1s) e assinam o cache. flush() grava na hora; clear() apaga o snapshot; unsubscribe() para de persistir. Dexie é peer dependency do store offline — instale (npm i dexie).

Recap

  • <QueryProvider> na raiz — um por app — entrega defaults calibrados; sobrescreva via defaultOptions ou client.
  • STALE_TIME / CACHE_TIME / REFETCH_TIME substituem números mágicos por presets nomeados.
  • createQueryKeys(scope, builders) gera keys tipadas e consistentes, com um all automático pra invalidação ampla.
  • Combine setQueryData (resposta imediata) com invalidateQueries (revalidação) usando a mesma key factory.
  • useOfflineMutation liga o motor offline ao cache: enqueue + optimistic update + flush + rollback.

Veja também

  • HTTP — o createApiClient que alimenta as queryFn
  • Offline — combinar com initialData pra fallback offline
  • PWA & Offline-First — service worker, background sync, status UI