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
Ligações
Apoia-se em
Contexto
Seção intitulada “Contexto”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)
Opções consideradas
Seção intitulada “Opções consideradas”Estado do servidor
Seção intitulada “Estado do servidor”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 trataredirect()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 obsoletosensureQueryData,fetchQueryeprefetchQuery, pelo que muitos exemplos online estão desatualizados
2. Apenas loaders do Router
Seção intitulada “2. Apenas loaders do Router”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
3. TanStack DB
Seção intitulada “3. TanStack DB”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.
Estado do cliente
Seção intitulada “Estado do cliente”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)
B. Zustand como store padrão
Seção intitulada “B. Zustand como store padrã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.
C. Jotai
Seção intitulada “C. Jotai”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.
D. TanStack Store
Seção intitulada “D. TanStack Store”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.
Decisão
Seção intitulada “Decisão”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-query5.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
QueryClientpor pedido, criado dentro degetRouter()emsrc/router.tsx, passado nocontextdo router e ligado comsetupRouterSsrQueryIntegration({ router, queryClient }). Nunca umQueryClientao 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: 0no router para que cada preload respeite a frescura definida pelo Query. Os loaders fazemawait 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 comuseSuspenseQuery, não comRoute.useLoaderData(). UsarqueryClient.query(), não os obsoletosensureQueryData/prefetchQuery/fetchQuery staleTimepredefinido de 30 s noQueryClient, 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. OuseQuerysimples 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ãoPOST. AqueryFnchama umacreateServerFn()(GETpor predefinição) que nunca escreve. As mutações chamamcreateServerFn({ method: "POST" })através deuseServerFndentro deuseMutation, 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()maisrouter.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 deloaderDepse 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
createStoredentro de um provider (useState(() => createStore(...))) e lê-las comuseStore— nunca uma storecreate()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.
Consequências
Seção intitulada “Consequências”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
ensureQueryDataeprefetchQuery; 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-querysã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.

