Este contenido solo está disponible en Portugués.
Aún sin traducción para este idioma.
Estado de Cliente e Servidor: Use Zustand e TanStack Query
O Redux não foi construído pra guardar a resposta da sua API. Foi construído pra gerenciar estado de interface. Em algum momento, esse limite desapareceu — e uma camada de boilerplate surgiu pra tapar o buraco que não deveria existir.

Dois problemas que pareciam um
Estado em uma aplicação React tem duas formas radicalmente diferentes. Misturá-las no mesmo lugar é a origem de boa parte da complexidade que você carrega em todo projeto.
Estado de servidor
Vive em um servidor remoto
- Lista de produtos da API
- Perfil do usuário logado
- Histórico de pedidos, resultados de busca
- Precisa de cache, refetch, deduplicação
- Fica desatualizado. Falha. Precisa de retry.
Estado de cliente
Vive no browser
- O sidebar está aberto ou fechado?
- Qual aba está ativa?
- O que o usuário digitou no filtro?
- Tema, visibilidade do modal, step do wizard
- Não precisa de cache nem de refetch
Quando os dois vão pro mesmo Redux slice — ou pra um Context gigante — você acaba escrevendo loading reducers, error reducers, lógica de cache e mecanismos de refetch na mão. Toda essa infraestrutura já existe em bibliotecas construídas especificamente pra estado de servidor.
A solução não foi trocar o Redux por algo melhor. Foi parar de pedir pro Redux resolver um problema que não é dele.
Zustand cuida do estado de cliente. TanStack Query cuida do estado de servidor. Cada um resolve exatamente um problema — e os dois não interferem um no outro.
Zustand: estado global sem cerimônia
Zustand parte de uma premissa: uma store é só um hook. Sem providers envolvendo o app inteiro. Sem actions, reducers ou dispatch. Sem boilerplate que existe apenas porque o padrão exige.
Estado e funções de atualização vivem juntos na mesma store. Componentes se inscrevem em fatias específicas e só re-renderizam quando aquela fatia muda.
// store/ui.ts
import { create } from 'zustand'
interface UIState {
sidebarAberto: boolean
tema: 'claro' | 'escuro'
alternarSidebar: () => void
setTema: (tema: 'claro' | 'escuro') => void
}
export const useUIStore = create<UIState>()((set) => ({
sidebarAberto: false,
tema: 'claro',
alternarSidebar: () =>
set((state) => ({ sidebarAberto: !state.sidebarAberto })),
setTema: (tema) => set({ tema }),
}))Usando no componente — sem Provider, sem dispatch:
function Sidebar() {
// subscrição seletiva: só re-renderiza quando sidebarAberto muda
const sidebarAberto = useUIStore(s => s.sidebarAberto)
const alternarSidebar = useUIStore(s => s.alternarSidebar)
return (
<aside className={sidebarAberto ? 'aberto' : 'fechado'}>
<button onClick={alternarSidebar}>Menu</button>
</aside>
)
}Subscrição seletiva importa. Se você desestruturar a store inteira com useUIStore(), o componente re-renderiza em toda mudança de estado — mesmo as que não afetam ele. Prefira useUIStore(s => s.sidebarAberto) pra subscrever só ao que o componente usa.
TanStack Query: estado de servidor que se gerencia sozinho
TanStack Query trata dados de API como um problema de cache, não de estado. Cada chamada recebe uma chave de cache. A biblioteca decide quando buscar, quando servir do cache, quando refazer a requisição em background — e o que mostrar enquanto os dados estão carregando ou desatualizados.
// hooks/useProdutos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function useProdutos() {
return useQuery({
queryKey: ['produtos'],
queryFn: () =>
fetch('/api/produtos').then(r => r.json()),
staleTime: 1000 * 60 * 5, // 5 min antes do refetch em background
})
}
export function useCriarProduto() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (produto: NovoProduto) =>
fetch('/api/produtos', {
method: 'POST',
body: JSON.stringify(produto),
}).then(r => r.json()),
// invalida o cache após a mutação — dispara novo fetch
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['produtos'] })
},
})
}O componente que consome fica limpo — sem gerenciar loading, error ou cache manualmente:
function ListaProdutos() {
const { data: produtos, isPending, isError } = useProdutos()
const criarProduto = useCriarProduto()
if (isPending) return <Spinner />
if (isError) return <MensagemDeErro />
return (
<ul>
{produtos.map(p => <ItemProduto key={p.id} produto={p} />)}
</ul>
)
}O antes e o depois que mudou como eu penso em estado
Mesma feature — lista de produtos com filtro — usando Redux versus a combinação Zustand + TanStack Query.
✕ Abordagem Redux
// slice/produtos.ts
const produtosSlice = createSlice({
name: 'produtos',
initialState: {
itens: [],
loading: false,
error: null,
filtro: '',
},
reducers: {
setFiltro: (state, action) => {
state.filtro = action.payload
},
},
extraReducers: (builder) => {
builder
.addCase(buscarProdutos.pending,
(s) => { s.loading = true })
.addCase(buscarProdutos.fulfilled,
(s, a) => {
s.loading = false
s.itens = a.payload
})
.addCase(buscarProdutos.rejected,
(s, a) => {
s.loading = false
s.error = a.error.message
})
}
})
// thunk/produtos.ts
export const buscarProdutos =
createAsyncThunk(
'produtos/buscar',
async () => {
const res = await fetch('/api/produtos')
return res.json()
}
)
// componente
function ListaProdutos() {
const dispatch = useDispatch()
const { itens, loading,
error, filtro } =
useSelector(s => s.produtos)
useEffect(() => {
dispatch(buscarProdutos())
}, [])
if (loading) return <Spinner />
if (error) return <Erro />
return (
<>
<input value={filtro}
onChange={e => dispatch(
setFiltro(e.target.value)
)}
/>
{itens
.filter(p =>
p.nome.includes(filtro))
.map(p => ...)}
</>
)
}✓ Zustand + TanStack Query
// store/filtro.ts
export const useFiltroStore =
create()((set) => ({
filtro: '',
setFiltro: (f) =>
set({ filtro: f }),
}))
// hooks/useProdutos.ts
export function useProdutos() {
return useQuery({
queryKey: ['produtos'],
queryFn: () =>
fetch('/api/produtos')
.then(r => r.json()),
})
}
// componente
function ListaProdutos() {
const { data, isPending,
isError } = useProdutos()
const { filtro, setFiltro } =
useFiltroStore()
if (isPending) return <Spinner />
if (isError) return <Erro />
return (
<>
<input value={filtro}
onChange={e =>
setFiltro(e.target.value)}
/>
{data
.filter(p =>
p.nome.includes(filtro))
.map(p => ...)}
</>
)
}A versão Redux tem mais linhas. Mas o custo real não é de linhas — é de rastreabilidade. Pra entender o que o componente faz, você percorre actions, thunks, reducers e selectors antes de chegar ao render. Na versão com Zustand e TanStack Query, cada peça está onde o problema mora: busca de dados no hook de query, estado de UI na store, renderização no componente.
Quando os dois trabalham juntos: filtro que dispara refetch
O ponto forte aparece quando o estado do Zustand influencia as queries do TanStack Query. Um filtro na store vira dependência da chave de cache — o TanStack Query refaz a requisição automaticamente quando o valor muda.
// A chave inclui o filtro do Zustand
// Quando filtro muda → refetch automático
export function useProdutosFiltrados() {
const filtro = useFiltroStore(s => s.filtro)
return useQuery({
queryKey: ['produtos', filtro],
queryFn: () =>
fetch(`/api/produtos?q=${filtro}`).then(r => r.json()),
// não busca com menos de 2 caracteres
enabled: filtro.length > 2,
})
}
// Query dependente: só busca os pedidos
// depois que o usuário estiver disponível
export function useDetalhesPedido(pedidoId: string) {
const { data: usuario } = useUsuario()
return useQuery({
queryKey: ['pedidos', pedidoId],
queryFn: () => buscarPedido(pedidoId, usuario!.token),
enabled: !!usuario, // só executa quando usuario existe
})
}Update otimista sem saga nem middleware
Atualizar a UI antes da resposta do servidor — update otimista — exigia sagas, thunks customizados ou middleware. No TanStack Query é configuração.
const alternarFavorito = useMutation({
mutationFn: (produtoId: string) =>
fetch(`/api/favoritos/${produtoId}`, { method: 'POST' }),
// atualiza o cache imediatamente antes da requisição terminar
onMutate: async (produtoId) => {
await queryClient.cancelQueries({ queryKey: ['produtos'] })
const anterior = queryClient.getQueryData(['produtos'])
queryClient.setQueryData(['produtos'], (old: Produto[]) =>
old.map(p =>
p.id === produtoId
? { ...p, favorito: !p.favorito }
: p
)
)
return { anterior } // snapshot pra rollback
},
// se falhar, volta ao estado anterior
onError: (err, produtoId, context) => {
queryClient.setQueryData(['produtos'], context?.anterior)
},
})Quando essa combinação não faz sentido
- Estado local resolve? Use
useState. Não adicione Zustand pra estado que não precisa ser compartilhado entre componentes. - App pequeno, pouca interação com API? Zustand sozinho, sem TanStack Query, é suficiente.
- Next.js com Server Components pesados? TanStack Query compete com RSC na busca de dados. Em apps muito orientados ao servidor, talvez não precise dele — ou use só pra mutações no cliente.
- GraphQL? Apollo Client ou urql já gerenciam cache e estado de servidor. Adicionar TanStack Query cria redundância.
O custo real dessa arquitetura é em times que não internalizaram a distinção entre estado de servidor e estado de cliente. Quando isso não está claro, respostas de API acabam no Zustand — e você volta ao problema original com uma nova camada de complexidade por cima.
O que fica depois de ler isso
- Estado de servidor precisa de cache, refetch e retry. TanStack Query entrega tudo isso.
- Estado de cliente é UI — sidebar, filtros, tema. Zustand gerencia com uma store que é um hook.
- Subscrição seletiva no Zustand — use store(s => s.campo) em vez de destruturar tudo.
- Query key com estado do Zustand — filtros na store viram dependências do cache, com refetch automático.
- Update otimista — onMutate atualiza o cache imediatamente, onError faz rollback se necessário.
- Não é pra todo projeto — apps pequenos, GraphQL ou Next.js com RSC têm soluções mais adequadas.
O boilerplate do Redux não era um problema de verbosidade. Era um sintoma de estar usando a ferramenta certa no lugar errado.
Estado de servidor tem ciclo de vida próprio — ele fica desatualizado, falha, precisa de retry, precisa de cache. Tratar isso como estado de UI significa reimplementar, na mão, o que o TanStack Query já resolve.
Quando cada problema tem a ferramenta certa, o código que sobra é só o que importa.



