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 TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Expanda o seu negócio com ECOSIRE
Soluções empresariais em ERP, comércio eletrônico, IA, análise e automação.
Artigos Relacionados
Tutorial do Shopify App Bridge 4: crie aplicativos incorporados em 2026
Crie aplicativos de administração integrados do Shopify com App Bridge 4: tokens de sessão, troca de tokens, navegação, modais, seletores de recursos e configuração do Polaris React 13.
Guia de migração de componentes de servidor React 19 2026: padrões reais de produção
Guia de migração de componentes do servidor React 19 testado em batalha: busca de dados, streaming, armadilhas de suspense, limites cliente/servidor, armadilhas e ganhos de desempenho medidos.
Padrões de API empresarial NestJS 11
Domine os padrões empresariais do NestJS 11: protetores, interceptores, pipes, multilocação e design de API pronto para produção para sistemas de back-end escaláveis.