Pular para o conteúdo principal

ADR-003: Zustand para Estado Global

CampoValor
Status✅ Accepted
Data2024-Q1
DecisoresTime de desenvolvimento
ImpactoMédio — afeta carrinho, UI do admin e fluxo de logout

Contexto

Um e-commerce necessita de estado global para, no mínimo, o carrinho de compras. O carrinho precisa ser acessível em toda a aplicação (header com contador de itens, página de produto com botão "adicionar", página de checkout com resumo), persistido entre recargas de página e sincronizado com o servidor no momento do checkout.

Além do carrinho, o painel administrativo tem estado de UI (modais abertos, filtros de listagem, estado de edição de produto) que precisa ser compartilhado entre componentes sem prop drilling excessivo.

Os requisitos específicos que guiaram esta decisão foram:

  1. Persistência em localStorage — o carrinho não pode ser perdido quando o usuário fecha e reabre o navegador.
  2. Acesso fora do React tree — ao fazer logout, o carrinho deve ser limpo. A lógica de logout pode ocorrer em um Server Action ou em um Route Handler, de onde não é possível chamar hooks React.
  3. Sem re-renders excessivos — o header exibe o contador do carrinho. Se o contador re-renderizar toda vez que qualquer outra parte do estado global mudar, a performance será degradada.
  4. Bundle size — o estado global não deve adicionar peso significativo ao bundle do cliente.
  5. SSR sem hydration mismatch — o carrinho persiste no localStorage (que não existe no servidor). A solução precisa evitar que o HTML gerado no servidor divirja do HTML renderizado no cliente.

Decisão

Adotamos Zustand como biblioteca de estado global, com o middleware persist configurado com partialize para excluir estado volátil da serialização.

A store do carrinho (store/cart.ts) usa:

export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
isOpen: false, // ← estado volátil: NÃO persiste
addItem: (item) => set((state) => ({ ... })),
clearCart: () => set({ items: [] }),
}),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items }), // ← somente `items` vai para localStorage
}
)
);

O campo isOpen (que controla se o drawer do carrinho está aberto) é excluído da serialização pelo partialize. Se fosse persistido, um usuário que fechou o browser com o drawer aberto voltaria com o drawer aberto — uma experiência ruim. Mais importante: isOpen: true serializado causaria um hydration mismatch no SSR, pois o servidor renderizaria o drawer fechado (estado padrão) e o cliente imediatamente abriria, causando um flash visual.

Para limpar o carrinho fora do React tree (ex.: no handler de logout):

// Em qualquer lugar, inclusive fora de componentes React
useCartStore.getState().clearCart();

Justificativa

Bundle size

Zustand tem ~3 KB minificado + gzipado. Redux Toolkit tem ~10 KB. Para uma aplicação onde o bundle é servido por hosting compartilhado, cada KB importa.

Zero boilerplate

Com Redux Toolkit, criar uma feature de carrinho exige: createSlice, configureStore, Provider, useSelector, useDispatch e um selector para cada pedaço de dado. Com Zustand:

// Zustand: define estado e ações no mesmo lugar
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));

// Uso em componente
const { items, addItem } = useCartStore();

Acesso fora do React tree

// Redux: impossível sem dispatch fora de um componente ou middleware
// Zustand: direto e simples
useCartStore.getState().clearCart(); // funciona em Server Actions, Route Handlers, utils

Este padrão é usado no handler de logout para garantir que o carrinho seja sempre limpo quando a sessão expira.

Prevenção de hydration mismatch

A configuração partialize resolve o problema fundamental de persistência no SSR: apenas dados que existem no servidor (estado padrão sem localStorage) são comparados no hydration. O Zustand com persist inicializa no estado padrão no servidor e hidrata com o localStorage apenas no cliente, após a montagem do componente.


Consequências

Positivas

  • Bundle pequeno (~3 KB) — impacto mínimo no Largest Contentful Paint.
  • Zero boilerplate — novas stores podem ser criadas em ~10 linhas.
  • Acesso fora do React tree — logout limpa o carrinho sem necessidade de prop drilling ou contexto.
  • partialize previne hydration mismatchisOpen e outros flags de UI não são serializados.
  • Devtools — Zustand tem integração com Redux DevTools para inspeção de estado em desenvolvimento.

Negativas / Ressalvas

  • Menos estrutura imposta — ao contrário do Redux, o Zustand não impõe convenções de organização. Em equipes maiores, isso pode levar a inconsistências se não houver um padrão estabelecido para criação de stores.
  • Sem time-travel debugging nativo — o Redux DevTools com time-travel é mais poderoso para debugging de fluxos complexos. Para o escopo atual do projeto, isso não é necessário.
  • Persistência em localStorage não criptografada — itens do carrinho (nome do produto, preço, quantidade) ficam visíveis em localStorage. Isso é aceitável para dados não sensíveis. Dados sensíveis (tokens, dados de pagamento) nunca devem ser colocados em uma store persistida.

Alternativas consideradas

Context API do React (descartado)

Motivo: O Context API causa re-render em todos os consumidores do contexto sempre que qualquer valor do contexto muda. Para o carrinho, que é acessado pelo header (contador), pela página de produto (botão "adicionar") e pelo checkout (resumo), isso significaria re-renders desnecessários em todos esses componentes quando qualquer coisa no contexto mudar. O Zustand usa seletores granulares: const count = useCartStore(s => s.items.length) só re-renderiza quando items.length muda.

Redux Toolkit (descartado)

Motivo: O Redux adiciona overhead de boilerplate (slices, store, Provider, selectors) que não se justifica para o estado relativamente simples do carrinho e da UI do admin. O bundle do Redux Toolkit (~10 KB) é mais que 3x o do Zustand (~3 KB). A necessidade de acessar o estado fora do React tree seria resolvível com Redux (via store.dispatch()), mas com mais complexidade de setup.

Jotai (não avaliado em detalhe)

Motivo: Abordagem atômica interessante, mas menos familiar para a equipe. A necessidade de persistência e acesso fora do React tree é mais naturalmente resolvida com o modelo de store do Zustand. Pode ser avaliado em projetos futuros.


Referências