Pular para o conteúdo

ADR-0012 Obtenção de dados e estado

Obtenção de dados e estado padrão da empresa — TanStack Query por trás dos loaders do Router para o estado do servidor, o URL e o React para o estado do cliente, Zustand só quando necessário.

Decisão
TanStack Query + loaders; URL + estado do ReactConstruir a app
Estado
Aceite
Data
Âmbito
Predefinição da empresa para todas as novas apps

O TanStack Start (ADR-0002) dá-nos loaders de rotas e server functions; a nossa UI lê e escreve através de server functions, e as server routes em /api/* servem clientes externos (ADR-0036). Falta definir quem é dono dos dados depois de chegarem ao browser e onde vive o estado só de UI.

Dois tipos de estado, com necessidades diferentes:

  • Estado do servidor — dados cuja fonte de verdade é o Postgres (ADR-0007). Precisa de cache, deduplicação, refetch quando desatualizado e invalidação após escritas. Tem de renderizar no servidor e fazer streaming com SSR, e depois hidratar sem ir buscar os dados duas vezes
  • Estado do cliente — estado de UI que nunca toca no servidor: filtros, separadores, painéis abertos, passos de um wizard. Parte dele deve sobreviver a um recarregamento ou a um link partilhado; a maioria é local a um componente

Restrições:

  • O SSR serve muitos utilizadores a partir de um só processo. Tudo o que é criado ao nível do módulo no servidor é partilhado entre pedidos — uma cache ou store com dados de utilizadores aí passa-os ao utilizador seguinte
  • As escritas têm rate limiting, as leituras não (ADR-0010): o limitador cobre os pedidos que não sejam GET, pelo que leituras e escritas têm de usar o método HTTP certo
  • O estado de formulários é uma decisão à parte (ADR-0013)

1. TanStack Query, com prefetch nos loaders do Router

Seção intitulada “1. TanStack Query, com prefetch nos loaders do Router”

Os loaders iniciam o fetch com queryClient.query(); os componentes leem a cache com useSuspenseQuery.

Prós

  • Uma cache por entidade, partilhada por todas as rotas e componentes que pedem a mesma chave — com deduplicação, refetch em segundo plano, polling, listas infinitas e mutações incluídos
  • Integração SSR oficial (@tanstack/react-router-ssr-query): desidrata no servidor, faz streaming das queries que resolvem durante a renderização, hidrata no cliente e trata redirect() lançado a partir de queries
  • Os loaders continuam a coordenar a navegação e o preloading, logo sem cascatas de pedidos
  • Mantido pela mesma equipa que o Router e o Start; MIT; a 5.x é madura (5.104.5)

Contras

  • Existem duas caches (a dos loaders do Router e a do Query); temos de escolher um dono — ver Decisão
  • As chaves das queries são disciplina nossa: uma chave que não coincide significa um fetch duplicado ou um ecrã desatualizado
  • A API imperativa mudou recentemente: queryClient.query() substitui os agora obsoletos ensureQueryData, fetchQuery e prefetchQuery, pelo que muitos exemplos online estão desatualizados

A cache de loaders integrada do Router (stale-while-revalidate) mais router.invalidate() após escritas.

Prós

  • Sem dependência extra; uma cache, um modelo mental

Contras

  • Em cache por match de rota, não por entidade — duas rotas que mostram o mesmo registo vão buscá-lo duas vezes e desatualizam-se de forma independente
  • Sem estado de mutações, atualizações otimistas, polling nem ferramentas para listas infinitas; cada produto reconstrói-os

Coleções reativas do lado do cliente com live queries e escritas otimistas, construídas sobre o Query.

Prós: joins em tempo real entre coleções no browser; UI otimista instantânea. Contras: pré-1.0 (@tanstack/react-db 0.5.11) — a API ainda pode mudar debaixo de um padrão da empresa. Assenta no Query, pelo que começar com o Query mantém esta porta aberta.

Prós: pequeno, simples. Contras: menos funcionalidades do que o Query (mutações, listas infinitas e devtools são mais limitados) e sem integração SSR oficial com o TanStack Router.

A. Search params do URL + estado React, uma store só quando necessário

Seção intitulada “A. Search params do URL + estado React, uma store só quando necessário”

Estado partilhável nos search params, validado pelo Zod em validateSearch; estado local em useState, useReducer ou contexto.

Prós

  • Sem dependências; filtros, separadores e páginas sobrevivem a recarregamentos, à navegação para trás/para a frente e a links partilhados
  • Os search params são tipados e validados pelos mesmos schemas Zod que usamos noutros sítios (ADR-0005); os schemas Zod 4 encaixam diretamente em validateSearch
  • A maior parte do “estado global” acaba por ser estado do servidor (Query) ou estado do URL — sobra pouco para uma store

Contras

  • As equipas têm de concordar sobre quando o estado é grande o suficiente para justificar uma store; é preciso uma regra escrita (ver Decisão)

Prós: minúsculo, familiar, sem opiniões impostas; 5.x estável (5.0.15). Contras: uma store desde o primeiro dia convida a copiar dados do servidor para lá, criando uma segunda cache desatualizada. Com SSR tem de ser criada por pedido, não com um create() ao nível do módulo.

Prós: os átomos dão re-renders de granularidade fina. Contras: um modelo mental diferente; a 3.0 saiu em 2026-09 e ainda está a assentar.

Prós: usada dentro das próprias bibliotecas TanStack. Contras: pré-1.0 (0.11.2, 1.0 em alpha); documentação escassa para uso ao nível da app.

O TanStack Query, com prefetch feito pelos loaders do Router, é dono do estado do servidor. O URL e o estado do React são donos do estado do cliente, com o Zustand só quando surgir de facto estado de cliente que atravesse a árvore. Este é o padrão da empresa para todas as novas apps.

Em concreto:

  • Pacotes: @tanstack/react-query 5.x e @tanstack/react-router-ssr-query. Os intervalos de peer dependencies prendem-nos às versões do Router e do Query, pelo que o Query, o Router, o Start e a integração são atualizados em conjunto
  • Um QueryClient por pedido, criado dentro de getRouter() em src/router.tsx, passado no context do router e ligado com setupRouterSsrQueryIntegration({ router, queryClient }). Nunca um QueryClient ao nível do módulo — no servidor partilharia os dados de um utilizador com o seguinte
  • O Query é dono dos dados; o Router coordena. Definir defaultPreloadStaleTime: 0 no router para que cada preload respeite a frescura definida pelo Query. Os loaders fazem await context.queryClient.query({ ...options, staleTime: "static" }) para preencher a cache (os dados em cache são usados tal como estão, os dados em falta são obtidos); os componentes leem com useSuspenseQuery, não com Route.useLoaderData(). Usar queryClient.query(), não os obsoletos ensureQueryData / prefetchQuery / fetchQuery
  • staleTime predefinido de 30 s no QueryClient, para que os dados renderizados no servidor não sejam obtidos de novo no instante em que hidratam. Ajustar por query
  • Os dados necessários na primeira renderização usam useSuspenseQuery — corre no servidor e faz streaming. O useQuery simples não corre no servidor; reservá-lo para dados só de cliente (por exemplo, polling depois do carregamento)
  • As query options vivem em factories junto da funcionalidade — queryOptions({ queryKey, queryFn }) — e são partilhadas pelo loader, pelo componente e pela invalidação. As chaves começam pela entidade: ["projects", projectId]
  • As leituras são server functions GET, as escritas são POST. A queryFn chama uma createServerFn() (GET por predefinição) que nunca escreve. As mutações chamam createServerFn({ method: "POST" }) através de useServerFn dentro de useMutation, de modo que toda a escrita passa pelo limitador (ADR-0010)
  • Depois de uma escrita, invalidar o dono: queryClient.invalidateQueries({ queryKey }), devolvendo a promise para que a mutação fique pendente até o refetch terminar. router.invalidate() só quando o contexto da rota também muda. Escrita confirmada mais invalidação é a predefinição; atualizações otimistas apenas onde a UX o exija, com rollback
  • O início e o fim de sessão limpam tudo: queryClient.clear() mais router.invalidate(), para que nenhum dado privado da identidade anterior fique na cache
  • O estado de cliente partilhável vive no URL: schemas Zod 4 passados diretamente a validateSearch, usando .catch() para fallbacks, de modo que um link malformado mostre os valores predefinidos e não um erro. Não instalar @tanstack/zod-adapter — existe para o Zod 3. Passar os valores de pesquisa de que um loader depende através de loaderDeps e para dentro da chave da query
  • O estado local usa useState / useReducer, elevado ou colocado em contexto quando componentes irmãos o partilham
  • Zustand 5 apenas para estado de cliente que atravessa a árvore e que não é nem dados do servidor nem digno de ir para o URL (por exemplo, a seleção de um editor, um canvas com vários passos). Criar as stores com createStore dentro de um provider (useState(() => createStore(...))) e lê-las com useStore — nunca uma store create() ao nível do módulo. Nunca copiar dados do servidor para uma store

Os fatores decisivos: uma cache por entidade com mutações e streaming SSR incluídos, mantida pela mesma equipa do nosso router; estado do URL tipado pelos schemas Zod que já temos; e nenhuma store global até um produto provar que precisa de uma. Aceitamos ser donos da disciplina das chaves das queries e de manter as versões do Query e do Router em sintonia.

Mais fácil:

  • Os dados carregam em paralelo com a navegação, renderizam no servidor, fazem streaming e hidratam sem um segundo fetch
  • Mutações, estados de carregamento e de erro, retries, polling e listas infinitas vêm de uma só biblioteca em todos os produtos
  • Filtros, separadores e paginação são linkáveis e sobrevivem a recarregamentos; os tipos vêm do Zod
  • O TanStack DB continua em aberto: assenta no Query, pelo que um produto o pode adotar mais tarde sem alterar a camada de dados

Mais difícil:

  • Disciplina das chaves das queries: uma factory por query, uma forma de chave por entidade, verificada na revisão
  • A maioria dos exemplos online ainda usa ensureQueryData e prefetchQuery; continuam a funcionar, mas estão obsoletos e serão removidos na próxima major do Query
  • Cada pedido constrói o seu próprio QueryClient; um client ou uma store ao nível do módulo é uma fuga de dados entre utilizadores, não apenas um bug — rever à procura disso
  • O Query, o Router, o Start e o @tanstack/react-router-ssr-query são atualizados em conjunto por causa dos intervalos de peer dependencies
  • O staleTime é uma decisão de produto por query; demasiado baixo faz refetch constante, demasiado alto mostra dados antigos

Do que abdicamos: da simplicidade de uma única cache dos loaders do Router por si sós, das live queries do TanStack DB até chegar à 1.0 e da comodidade de uma store global. Aceite — a cache por entidade e as ferramentas de mutações justificam uma biblioteca, e a maior parte do estado global pertence ao Query ou ao URL de qualquer forma.