Como usar React com Zustand: Guia Completo
Introdução
Gerenciar estado em aplicações React sempre foi um dos temas mais debatidos na comunidade front-end. Passamos por fases: começamos com o Redux e sua cerimônia verbosa de actions, reducers e constants. Experimentamos o Context API e descobrimos seus gargalos de performance. Testamos o MobX, o Recoil e tantas outras bibliotecas. E então surgiu o Zustand.
Zustand (que significa "estado" em alemão) é uma biblioteca de gerenciamento de estado minimalista, criada pela Pmndrs (a mesma equipe por trás do react-three-fiber e do Jotai). A proposta é simples: oferecer uma API enxuta, sem boilerplate, com excelente performance e total compatibilidade com o ecossistema React.
Neste guia, vou mostrar como integrar React com Zustand de forma prática, desde a configuração inicial até padrões avançados que uso em projetos de produção. Se você já tem pelo menos dois anos de experiência com React, vai aproveitar cada seção.
Por que usar React com Zustand
O problema do gerenciamento de estado no React
O React, por design, resolve estado local muito bem com useState e useReducer. O problema começa quando você precisa compartilhar estado entre componentes distantes na árvore. A solução nativa é o Context API, mas ela tem uma limitação fundamental: qualquer atualização no valor do contexto causa re-render em todos os componentes consumidores, mesmo que eles não usem a parte do estado que mudou.
O Redux resolve esse problema, mas cobra um preço alto em verbosidade. Para adicionar uma simples feature, você precisa criar action types, action creators, reducers e conectar tudo com selectors. Em projetos grandes isso escala razoavelmente bem, mas em projetos de médio porte acaba gerando uma quantidade desproporcional de código.
O que torna o Zustand diferente
O Zustand resolve o problema do estado global com três princípios:
- Store fora da árvore React: o estado vive fora do React, o que significa que não depende de Providers, não sofre com re-renders desnecessários e pode ser acessado fora de componentes.
- API baseada em hooks: cada store expõe um hook customizado que os componentes usam com selectors, garantindo re-render apenas quando o dado selecionado muda.
- Zero boilerplate: criar uma store leva menos de dez linhas de código.
Comparado a alternativas populares, o Zustand ocupa apenas cerca de 1.2kB gzipped, não exige nenhum Provider na raiz da aplicação e funciona perfeitamente com TypeScript sem configurações extras.
Setup do projeto React com Zustand
Instalação e configuração inicial
Assumindo que você já tem um projeto React com TypeScript (criado com Vite, Next.js ou qualquer outro setup), a instalação é direta:
// Terminal
// npm install zustand
// ou
// pnpm add zustand
// ou
// yarn add zustandNão existe arquivo de configuração, Provider wrapper ou middleware obrigatório. Após instalar, você já pode criar sua primeira store.
Estrutura de pastas recomendada
Em projetos reais, costumo organizar as stores da seguinte forma:
src/
stores/
useAuthStore.ts
useCartStore.ts
useUIStore.ts
types/
auth.ts
cart.tsCada store é um arquivo independente, focado em um domínio específico da aplicação. Isso facilita manutenção, testes e code splitting.
Exemplo prático: Store de autenticação
Vamos começar com um caso de uso que praticamente toda aplicação precisa: gerenciamento de autenticação.
// src/types/auth.ts
export interface User {
id: string;
name: string;
email: string;
role: "admin" | "user" | "editor";
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
export interface AuthActions {
login: (email: string, password: string) => Promise<void>;
logout: () => void;
clearError: () => void;
updateProfile: (data: Partial<User>) => void;
}
// src/stores/useAuthStore.ts
import { create } from "zustand";
import { persist, devtools } from "zustand/middleware";
import type { AuthState, AuthActions, User } from "../types/auth";
type AuthStore = AuthState & AuthActions;
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
error: null,
};
export const useAuthStore = create<AuthStore>()(
devtools(
persist(
(set, get) => ({
...initialState,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null }, false, "auth/loginStart");
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Falha na autenticação");
}
const data: { user: User; token: string } = await response.json();
set(
{
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
},
false,
"auth/loginSuccess"
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Erro desconhecido";
set(
{ isLoading: false, error: message },
false,
"auth/loginError"
);
}
},
logout: () => {
set({ ...initialState }, false, "auth/logout");
},
clearError: () => {
set({ error: null }, false, "auth/clearError");
},
updateProfile: (data: Partial<User>) => {
const currentUser = get().user;
if (!currentUser) return;
set(
{ user: { ...currentUser, ...data } },
false,
"auth/updateProfile"
);
},
}),
{
name: "auth-storage",
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
),
{ name: "AuthStore" }
)
);Alguns pontos importantes nesse exemplo:
O middleware persist salva automaticamente o estado no localStorage, mas usamos partialize para excluir campos transitórios como isLoading e error. O middleware devtools integra com o Redux DevTools, facilitando a depuração. Cada chamada de set recebe um terceiro argumento (o action name) que aparece no DevTools.
Consumindo a store nos componentes
// src/components/LoginForm.tsx
import { useAuthStore } from "../stores/useAuthStore";
import { FormEvent, useState } from "react";
export function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// Selectors granulares: o componente só re-renderiza
// quando esses valores específicos mudam
const login = useAuthStore((state) => state.login);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
await login(email, password);
};
return (
<form onSubmit={handleSubmit}>
{error && <p className="text-red-500">{error}</p>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
disabled={isLoading}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Senha"
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
{isLoading ? "Entrando..." : "Entrar"}
</button>
</form>
);
}Exemplo prático: Store de carrinho de compras com computed values
Esse segundo exemplo mostra um padrão mais sofisticado que é extremamente comum em e-commerce: estado derivado (computed values) e ações que dependem do estado atual.
// src/types/cart.ts
export interface CartItem {
productId: string;
name: string;
price: number;
quantity: number;
imageUrl: string;
}
// src/stores/useCartStore.ts
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import type { CartItem } from "../types/cart";
interface CartState {
items: CartItem[];
couponCode: string | null;
discountPercentage: number;
}
interface CartActions {
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
applyCoupon: (code: string) => Promise<boolean>;
clearCart: () => void;
}
interface CartComputedActions {
getTotalItems: () => number;
getSubtotal: () => number;
getTotal: () => number;
}
type CartStore = CartState & CartActions & CartComputedActions;
export const useCartStore = create<CartStore>()(
subscribeWithSelector((set, get) => ({
items: [],
couponCode: null,
discountPercentage: 0,
addItem: (item) => {
const { items } = get();
const existingItem = items.find(
(i) => i.productId === item.productId
);
if (existingItem) {
set(
{
items: items.map((i) =>
i.productId === item.productId
? { ...i, quantity: i.quantity + 1 }
: i
),
},
);
} else {
set({ items: [...items, { ...item, quantity: 1 }] });
}
},
removeItem: (productId) => {
set({
items: get().items.filter((i) => i.productId !== productId),
});
},
updateQuantity: (productId, quantity) => {
if (quantity <= 0) {
get().removeItem(productId);
return;
}
set({
items: get().items.map((i) =>
i.productId === productId ? { ...i, quantity } : i
),
});
},
applyCoupon: async (code: string) => {
try {
const response = await fetch(`/api/coupons/validate?code=${code}`);
const data = await response.json();
if (data.valid) {
set({
couponCode: code,
discountPercentage: data.discountPercentage,
});
return true;
}
return false;
} catch {
return false;
}
},
clearCart: () => {
set({ items: [], couponCode: null, discountPercentage: 0 });
},
// Computed values implementadas como funções
getTotalItems: () => {
return get().items.reduce((sum, item) => sum + item.quantity, 0);
},
getSubtotal: () => {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
getTotal: () => {
const subtotal = get().getSubtotal();
const discount = subtotal * (get().discountPercentage / 100);
return subtotal - discount;
},
}))
);
// Subscriber que roda fora de componentes React:
// sincroniza o badge do carrinho no título da página
useCartStore.subscribe(
(state) => state.items.length,
(itemCount) => {
document.title = itemCount > 0
? `(${itemCount}) Minha Loja`
: "Minha Loja";
}
);Repare no uso do middleware subscribeWithSelector: ele permite criar subscriptions granulares que reagem apenas a partes específicas do estado. No exemplo acima, o título da página só é atualizado quando o número de itens muda, ignorando qualquer outra atualização na store.
Boas práticas para Zustand em produção
Selectors granulares sempre
Nunca faça isso em componentes:
// Ruim: o componente re-renderiza em QUALQUER mudança na store
const state = useCartStore();
// Bom: re-renderiza apenas quando "items" muda
const items = useCartStore((state) => state.items);Se você precisa selecionar múltiplos campos, crie um selector que retorna um objeto e use shallow para comparação:
import { useShallow } from "zustand/react/shallow";
const { items, couponCode } = useCartStore(
useShallow((state) => ({
items: state.items,
couponCode: state.couponCode,
}))
);Separe estado de ações
Manter a separação entre a interface de estado e a interface de ações (como fizemos nos exemplos acima) facilita a tipagem, o entendimento e os testes unitários.
Stores pequenas e focadas
Resista à tentação de criar uma mega store com todo o estado da aplicação. Prefira stores pequenas e coesas, organizadas por domínio. Uma store para autenticação, outra para carrinho, outra para preferências de UI. Se duas stores precisam se comunicar, uma pode acessar a outra diretamente:
// Dentro de uma action na orderStore
const token = useAuthStore.getState().token;Testes unitários
Como a store é apenas uma função pura, testá-la é trivial:
import { useCartStore } from "./useCartStore";
describe("CartStore", () => {
beforeEach(() => {
useCartStore.setState({ items: [], couponCode: null, discountPercentage: 0 });
});
it("deve adicionar um item ao carrinho", () => {
useCartStore.getState().addItem({
productId: "1",
name: "Camiseta",
price: 49.9,
imageUrl: "/img/camiseta.jpg",
});
expect(useCartStore.getState().items).toHaveLength(1);
expect(useCartStore.getState().items[0].quantity).toBe(1);
});
it("deve incrementar quantidade de item existente", () => {
const item = {
productId: "1",
name: "Camiseta",
price: 49.9,
imageUrl: "/img/camiseta.jpg",
};
useCartStore.getState().addItem(item);
useCartStore.getState().addItem(item);
expect(useCartStore.getState().items).toHaveLength(1);
expect(Guias Relacionados
Explore mais guias técnicos
Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.
Ver todos os guias