Zustand State Management: Padrões para aplicativos complexos
Os primitivos de estado integrados do React (useState, useReducer, useContext) funcionam bem para estados de UI locais e compartilhados, mas enfrentam atritos em escala: provedores de contexto renderizam novamente a árvore inteira em cada mudança de estado, useReducer requer padrão de ação detalhada e a perfuração de suporte torna-se incontrolável. Redux resolve isso, mas ao custo de uma sobrecarga mental e clichê significativa.
Zustand ocupa um meio-termo perfeito: 2,9 KB, sem provedores, sem clichê, primeiro TypeScript e compatível com React DevTools. Ele se destaca no estado do aplicativo do lado do cliente – carrinhos de compras, estado da barra lateral, dados de formulário em várias etapas, preferências da interface do usuário – enquanto o TanStack Query lida com o estado do servidor. Este guia aborda os padrões Zustand v5 para aplicações de produção.
Principais conclusões
- Zustand é para estado somente cliente; use o TanStack Query para o estado do servidor - não sobreponha suas responsabilidades
- Não é necessário wrapper de provedor — o armazenamento é singleton em nível de módulo, acessível em qualquer lugar
- Use o padrão de fatias para grandes lojas: componha vários criadores de fatias em uma chamada
create()- O middleware
immerpermite sintaxe de mutação direta dentro de ações (sem operadores de propagação)- Middleware
persistcomlocalStorageousessionStoragepara estado de sessão cruzada- O middleware
devtoolsintegra-se ao Redux DevTools Extension para depuração de viagem no tempo- Seletores com
useShallowevitam re-renderizações desnecessárias de objetos rasos e iguais- Nunca armazene dados do servidor no Zustand — a invalidação se torna o seu problema
Loja Básica
// src/lib/stores/ui.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface UiState {
// State
sidebarOpen: boolean;
theme: 'light' | 'dark' | 'system';
commandPaletteOpen: boolean;
activeModal: string | null;
// Actions
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setTheme: (theme: UiState['theme']) => void;
openModal: (modalId: string) => void;
closeModal: () => void;
toggleCommandPalette: () => void;
}
export const useUiStore = create<UiState>()(
devtools(
(set) => ({
sidebarOpen: true,
theme: 'system',
commandPaletteOpen: false,
activeModal: null,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar'),
setSidebarOpen: (open) =>
set({ sidebarOpen: open }, false, 'setSidebarOpen'),
setTheme: (theme) =>
set({ theme }, false, 'setTheme'),
openModal: (modalId) =>
set({ activeModal: modalId }, false, 'openModal'),
closeModal: () =>
set({ activeModal: null }, false, 'closeModal'),
toggleCommandPalette: () =>
set(
(state) => ({ commandPaletteOpen: !state.commandPaletteOpen }),
false,
'toggleCommandPalette'
),
}),
{ name: 'ui-store' }
)
);
// Usage in components — select only what you need
function Sidebar() {
const isOpen = useUiStore((state) => state.sidebarOpen);
const toggle = useUiStore((state) => state.toggleSidebar);
return (
<aside className={cn('sidebar', isOpen && 'sidebar--open')}>
<button onClick={toggle}>Toggle</button>
</aside>
);
}
Loja de carrinho com persistência
// src/lib/cart.ts
import { create } from 'zustand';
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
licenseType: 'one-time' | 'subscription';
}
interface CartState {
items: CartItem[];
couponCode: string | null;
couponDiscount: number;
// Computed (derived state — use selectors, not stored values)
// subtotal, total, itemCount — computed in selectors
// Actions
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
applyCoupon: (code: string, discount: number) => void;
removeCoupon: () => void;
clearCart: () => void;
}
export const useCart = create<CartState>()(
devtools(
persist(
immer((set) => ({
items: [],
couponCode: null,
couponDiscount: 0,
addItem: (newItem) =>
set((state) => {
const existing = state.items.find((i) => i.productId === newItem.productId);
if (existing) {
existing.quantity += 1; // Immer allows direct mutation
} else {
state.items.push({ ...newItem, quantity: 1 });
}
}),
removeItem: (productId) =>
set((state) => {
state.items = state.items.filter((i) => i.productId !== productId);
}),
updateQuantity: (productId, quantity) =>
set((state) => {
if (quantity <= 0) {
state.items = state.items.filter((i) => i.productId !== productId);
} else {
const item = state.items.find((i) => i.productId === productId);
if (item) item.quantity = quantity;
}
}),
applyCoupon: (code, discount) =>
set((state) => {
state.couponCode = code;
state.couponDiscount = discount;
}),
removeCoupon: () =>
set((state) => {
state.couponCode = null;
state.couponDiscount = 0;
}),
clearCart: () =>
set((state) => {
state.items = [];
state.couponCode = null;
state.couponDiscount = 0;
}),
})),
{
name: 'ecosire-cart',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
// Only persist items and coupon, not derived state
items: state.items,
couponCode: state.couponCode,
couponDiscount: state.couponDiscount,
}),
}
),
{ name: 'cart-store' }
)
);
// Selectors — computed from state, not stored
export const selectSubtotal = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectTotal = (state: CartState) => {
const subtotal = selectSubtotal(state);
return subtotal * (1 - state.couponDiscount);
};
export const selectItemCount = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);
Padrão de fatia para grandes lojas
Para lojas complexas, divida em fatias e componha:
// src/lib/stores/slices/filters.slice.ts
import type { StateCreator } from 'zustand';
export interface FiltersSlice {
search: string;
status: string;
page: number;
sortField: string;
sortDirection: 'asc' | 'desc';
setSearch: (search: string) => void;
setStatus: (status: string) => void;
setPage: (page: number) => void;
setSort: (field: string, direction: 'asc' | 'desc') => void;
resetFilters: () => void;
}
const initialFilters = {
search: '',
status: 'all',
page: 1,
sortField: 'createdAt',
sortDirection: 'desc' as const,
};
export const createFiltersSlice: StateCreator<FiltersSlice> = (set) => ({
...initialFilters,
setSearch: (search) => set({ search, page: 1 }), // Reset page on search
setStatus: (status) => set({ status, page: 1 }),
setPage: (page) => set({ page }),
setSort: (field, direction) => set({ sortField: field, sortDirection: direction }),
resetFilters: () => set(initialFilters),
});
// src/lib/stores/slices/selection.slice.ts
import type { StateCreator } from 'zustand';
export interface SelectionSlice {
selectedIds: Set<string>;
selectAll: boolean;
toggleSelection: (id: string) => void;
selectAllItems: (ids: string[]) => void;
clearSelection: () => void;
}
export const createSelectionSlice: StateCreator<SelectionSlice> = (set) => ({
selectedIds: new Set(),
selectAll: false,
toggleSelection: (id) =>
set((state) => {
const next = new Set(state.selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedIds: next, selectAll: false };
}),
selectAllItems: (ids) =>
set({ selectedIds: new Set(ids), selectAll: true }),
clearSelection: () =>
set({ selectedIds: new Set(), selectAll: false }),
});
// src/lib/stores/contacts-table.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createFiltersSlice, type FiltersSlice } from './slices/filters.slice';
import { createSelectionSlice, type SelectionSlice } from './slices/selection.slice';
type ContactsTableStore = FiltersSlice & SelectionSlice;
export const useContactsTableStore = create<ContactsTableStore>()(
devtools(
(...args) => ({
...createFiltersSlice(...args),
...createSelectionSlice(...args),
}),
{ name: 'contacts-table-store' }
)
);
Evitando novas renderizações desnecessárias
Zustand renderiza novamente o componente quando o valor selecionado muda (por referência para objetos).
// PROBLEM: new object created on every render → infinite re-renders
const { search, page } = useContactsTableStore((state) => ({
search: state.search,
page: state.page,
}));
// SOLUTION 1: Select primitives individually
const search = useContactsTableStore((state) => state.search);
const page = useContactsTableStore((state) => state.page);
// SOLUTION 2: Use useShallow for object selections
import { useShallow } from 'zustand/react/shallow';
const { search, page } = useContactsTableStore(
useShallow((state) => ({ search: state.search, page: state.page }))
);
// SOLUTION 3: Select an action (actions never change reference)
const setSearch = useContactsTableStore((state) => state.setSearch);
Consulta Zustand + TanStack: Separação clara
A disciplina principal: Zustand para estado do cliente, TanStack Query para estado do servidor – nunca se sobrepõem.
// WRONG: storing server data in Zustand
const useContactsStore = create(() => ({
contacts: [], // Server data in Zustand — invalidation nightmare
fetchContacts: async () => { /* fetch and set */ },
}));
// CORRECT: TanStack Query for server data, Zustand for UI state
// TanStack Query
const { data: contacts } = useContacts({ page, limit, search });
// Zustand — only UI-specific state
const { search, page, setSearch, setPage } = useContactsTableStore();
A chave de consulta reage às mudanças de estado do Zustand, portanto a filtragem/classificação simplesmente funciona:
function ContactsPage() {
// UI state from Zustand
const search = useContactsTableStore((s) => s.search);
const page = useContactsTableStore((s) => s.page);
const status = useContactsTableStore((s) => s.status);
// Server state from TanStack Query, driven by Zustand state
const { data, isLoading } = useContacts({ search, page, status, limit: 20 });
return <ContactsTable data={data?.data ?? []} loading={isLoading} />;
}
Armazenamento de formulários em várias etapas
// src/lib/stores/onboarding.store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface OnboardingData {
company: { name: string; size: string; industry: string };
contact: { name: string; email: string; phone: string };
requirements: { erp: string; modules: string[]; budget: string };
}
interface OnboardingStore {
currentStep: number;
completed: Set<number>;
data: Partial<OnboardingData>;
setStep: (step: number) => void;
updateStepData: <K extends keyof OnboardingData>(
step: K,
data: OnboardingData[K]
) => void;
markStepComplete: (step: number) => void;
reset: () => void;
}
export const useOnboardingStore = create<OnboardingStore>()(
persist(
(set) => ({
currentStep: 0,
completed: new Set(),
data: {},
setStep: (step) => set({ currentStep: step }),
updateStepData: (key, stepData) =>
set((state) => ({
data: { ...state.data, [key]: stepData },
})),
markStepComplete: (step) =>
set((state) => ({
completed: new Set([...state.completed, step]),
})),
reset: () => set({ currentStep: 0, completed: new Set(), data: {} }),
}),
{
name: 'onboarding-progress',
storage: createJSONStorage(() => sessionStorage),
// Serialize Set properly
partialize: (state) => ({
...state,
completed: Array.from(state.completed),
}),
merge: (persisted: any, current) => ({
...current,
...persisted,
completed: new Set(persisted.completed ?? []),
}),
}
)
);
Testando Lojas Zustand
// src/lib/stores/__tests__/cart.store.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useCart, selectSubtotal, selectTotal } from '../cart';
describe('Cart Store', () => {
beforeEach(() => {
// Reset store state between tests
useCart.setState({
items: [],
couponCode: null,
couponDiscount: 0,
});
});
it('adds items to the cart', () => {
const { addItem } = useCart.getState();
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
const { items } = useCart.getState();
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(1);
});
it('increments quantity when same product added again', () => {
const { addItem } = useCart.getState();
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
const { items } = useCart.getState();
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(2);
});
it('calculates subtotal correctly', () => {
useCart.setState({
items: [
{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 2, licenseType: 'one-time' },
{ id: '2', productId: 'p2', name: 'B', price: 50, quantity: 1, licenseType: 'one-time' },
],
});
expect(selectSubtotal(useCart.getState())).toBe(250);
});
it('applies coupon discount', () => {
useCart.setState({
items: [{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 1, licenseType: 'one-time' }],
couponCode: 'SAVE20',
couponDiscount: 0.2,
});
expect(selectTotal(useCart.getState())).toBe(80);
});
});
Perguntas frequentes
Ainda preciso da API Context com Zustand?
Raramente. O singleton de nível de módulo do Zustand substitui a maioria dos casos de uso da API Context. O principal caso de uso restante para Context é a injeção de dependência — passando uma instância de armazenamento específica para uma subárvore (útil para testes ou quando você precisa de várias instâncias de armazenamento independentes). Para isso, Zustand fornece createStore() + useStore() que funcionam com um contexto React para a instância da loja.
Como o Zustand se compara ao Redux Toolkit?
Redux Toolkit tem mais estrutura (fatias, redutores, ações) e melhor integração com DevTools para depuração complexa de viagens no tempo. Zustand tem menos padrão, um pacote menor (2,9 KB vs ~30 KB para Redux + RTK) e não requer um provedor. Para a maioria das aplicações, o Zustand é mais simples e suficiente. Escolha Redux Toolkit se você tiver uma equipe grande que se beneficia de padrões impostos ou precisa de recursos avançados do DevTools.
Como faço para usar o Zustand com Next.js App Router SSR?
As lojas Zustand são singletons em nível de módulo — seguros para componentes de cliente, mas não podem ser usados em componentes de servidor (sem APIs de navegador, sem janela). Inicialize armazenamentos apenas em componentes 'use client'. Para hidratação SSR dos dados iniciais, use HydrationBoundary do TanStack Query para dados do servidor. Para estado somente cliente (carrinho, preferências de UI) que precisa persistir durante a navegação, use o middleware persist com localStorage.
O que é o middleware immer e quando devo usá-lo?
O middleware immer envolve a função set do Zustand com o Immer, permitindo que você escreva código mutante em ações (state.items.push(item)) que são realmente imutáveis nos bastidores. Use-o quando você tiver atualizações de estado aninhadas que sejam estranhas à sintaxe de propagação, como atualizar uma propriedade profundamente aninhada ou trabalhar com matrizes. Ele adiciona cerca de 6 KB ao seu pacote – vale a pena para lojas complexas e desnecessário para lojas simples.
Como evito fechamentos obsoletos em ações do Zustand?
Zustand passa o estado atual para o retorno de chamada set, portanto, sempre use a forma funcional ao ler o estado dentro de uma ação: set((state) => ({ count: state.count + 1 })) em vez de capturar state.count do fechamento. Para ações assíncronas que leem o estado após um await, chame getState() no ponto de uso em vez de capturar o estado no fechamento antes da espera.
Próximas etapas
A pegada mínima e o padrão de fatia combinável do Zustand tornam-no a ferramenta certa para o estado do cliente em aplicações React de qualquer tamanho. Combinado com o TanStack Query para estado do servidor, você tem uma arquitetura de gerenciamento de estado limpa e escalável, sem sobrecarga padrão.
ECOSIRE cria aplicativos React com Zustand + TanStack Query como pilha de gerenciamento de estado padrão – atendendo painéis de administração complexos, portais de clientes e fluxos de comércio eletrônico. Explore nossos serviços de engenharia de front-end para saber como arquitetamos aplicativos React escaláveis.
Escrito por
ECOSIRE Research and Development Team
Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.
Artigos Relacionados
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.
API Integration Patterns for OpenClaw Agents
Technical patterns for integrating OpenClaw AI agents with REST APIs, GraphQL, webhooks, and enterprise systems. Covers authentication, error handling, rate limiting, and testing.