Gerenciamento de Estado
Este documento descreve a estratégia de estado do projeto, os três stores Zustand disponíveis, as soluções para hidratação SSR e as diretrizes de quando usar cada abordagem de estado.
1. Estratégia
O projeto adota uma separação clara entre estado do servidor e estado do cliente:
| Tipo de dado | Onde vive | Tecnologia |
|---|---|---|
| Dados do banco (produtos, pedidos, usuário) | Servidor | React Server Components + Prisma |
| Estado de UI do carrinho | Cliente | Zustand + localStorage |
| Lista de favoritos | Cliente + API | Zustand com sincronização otimista |
| Preferências de UI do admin | Cliente | Zustand + localStorage |
| Estado de formulários | Componente local | useState ou react-hook-form |
| Estado de UI efêmero (modal aberto, tab ativa) | Componente local | useState |
Princípio fundamental
Não buscar no cliente o que pode ser buscado no servidor.
Se um dado está disponível em um Server Component, ele é passado como prop para o Client Component — nunca refetchado via useEffect + fetch. Isso elimina waterfalls de dados, estados de loading desnecessários e duplicação de lógica.
// Correto — dado vem do servidor via prop
async function ProdutoPage({ params }) {
const produto = await getProduto(params.slug); // RSC
return <PdpVariantSection produto={produto} />; // CC recebe via prop
}
// Evitar — buscar no cliente o que RSC já tem
function PdpVariantSection() {
const [produto, setProduto] = useState(null);
useEffect(() => { fetch("/api/produto/...").then(...) }, []); // desnecessário
}
2. useCartStore
O store mais central da aplicação — gerencia o carrinho de compras completo, incluindo itens, frete e cupom.
Arquivo: store/cart.ts
Chave no localStorage: "ljvemasearomas-cart"
Estado
interface CartStore {
items: CartItem[]; // itens no carrinho
isOpen: boolean; // gaveta do carrinho aberta ou fechada
shipping: ShippingQuote | null; // resultado da cotação de frete
coupon: CartCoupon | null; // cupom aplicado
}
CartItem
interface CartItem {
product: CartProduct;
quantity: number;
}
interface CartProduct {
id: string;
variantId?: string; // UUID da variante; undefined = produto sem variante
variantLabel?: string; // ex.: "200ml · Lavanda · Intensa"
name: string;
slug: string;
price: number;
image?: string;
stock: number;
}
A chave única de cada item no carrinho é calculada como productId quando não há variante, ou productId::variantId quando há. Isso permite ter o mesmo produto em variantes diferentes no mesmo carrinho.
ShippingQuote
interface ShippingQuote {
cep: string;
state: string;
city: string;
options: ShippingOption[];
selectedOptionId: string | null;
}
interface ShippingOption {
id: string;
name: string;
description: string;
price: number;
estimatedDays: string;
free: boolean;
}
CartCoupon
interface CartCoupon {
id: string;
code: string;
discount: number; // valor em centavos ou reais (conforme retorno da API)
}
Actions
| Action | Comportamento |
|---|---|
addItem(product, quantity?) | Adiciona ou incrementa item. Respeita limite de estoque. Abre a gaveta automaticamente. |
removeItem(productId, variantId?) | Remove o item correspondente à combinação produto+variante. |
updateQuantity(productId, variantId, quantity) | Atualiza quantidade; se quantity <= 0, remove o item. |
clearCart() | Esvazia itens, frete e cupom. |
openCart() | Abre a gaveta do carrinho. |
closeCart() | Fecha a gaveta do carrinho. |
toggleCart() | Alterna o estado da gaveta. |
setShippingQuote(quote) | Salva a cotação de frete. Auto-seleciona a primeira opção se nenhuma estiver selecionada. |
selectShippingOption(optionId) | Seleciona uma opção de frete dentre as cotadas. |
clearShipping() | Remove as opções de frete (mantém o CEP). |
applyCoupon(coupon) | Aplica um cupom validado pela API. |
removeCoupon() | Remove o cupom aplicado. |
Derived Values (valores calculados)
Os valores calculados são funções (não campos) porque dependem de outros campos do estado. Eles são recalculados a cada chamada.
| Função | Retorno |
|---|---|
total() | Soma de price × quantity de todos os itens |
itemCount() | Soma de todas as quantidades |
shippingCost() | Custo do frete selecionado. Retorna 0 se o subtotal atingir FREE_SHIPPING_THRESHOLD (R$ 200,00) |
orderTotal() | total() + shippingCost() - coupon.discount. Nunca negativo (mínimo 0) |
FREE_SHIPPING_THRESHOLD
// lib/constants.ts
export const FREE_SHIPPING_THRESHOLD = 200; // R$ 200,00
Quando total() >= FREE_SHIPPING_THRESHOLD, shippingCost() retorna 0 automaticamente, independentemente da opção de frete selecionada.
Persistência
persist(
(set, get) => ({ /* store */ }),
{
name: "ljvemasearomas-cart",
partialize: (state) => ({
items: state.items,
shipping: state.shipping,
coupon: state.coupon,
// isOpen NÃO é persistido — ver seção 5
}),
}
)
O campo isOpen é explicitamente excluído da persistência via partialize. Se fosse persistido, o carrinho estaria sempre aberto ao recarregar a página — um hydration mismatch entre o HTML estático do servidor (gaveta fechada) e o estado do localStorage (gaveta aberta).
Uso fora de componentes React
O store pode ser acessado fora de componentes usando .getState():
// Limpar carrinho após logout
import { useCartStore } from "@/store/cart";
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
useCartStore.getState().clearCart();
router.push("/auth/login");
}
3. useWishlistStore
Gerencia a lista de favoritos do usuário, com sincronização otimista contra a API.
Arquivo: store/wishlist.ts
Chave no localStorage: "ljvemasearomas-wishlist"
Estado e API
interface WishlistStore {
ids: string[]; // IDs dos produtos favoritados
isFavorite: (productId: string) => boolean;
toggle: (productId: string) => Promise<void>;
hydrate: () => Promise<void>;
}
Sincronização com a API
O método toggle implementa atualização otimista: o estado local é alterado imediatamente, e a chamada à API ocorre em segundo plano. Em caso de erro (rede ou 401 — usuário não logado), o estado é revertido e um toast é exibido.
toggle("product-id")
├── Atualiza state local imediatamente (otimista)
├── Chama DELETE /api/user/wishlist/:id (se era favorito)
│ └── 401 → reverte + toast "Entre na conta"
│ └── erro → reverte + toast "Erro de rede"
└── Chama POST /api/user/wishlist (se não era favorito)
└── 401 → reverte + toast "Entre na conta"
└── erro → reverte + toast "Erro de rede"
Hidratação inicial
O método hydrate() deve ser chamado uma única vez no mount do layout da loja. Ele sobrescreve os IDs do localStorage com a lista real da API (relevante quando o usuário favorita em um dispositivo e acessa em outro):
// app/(store)/layout.tsx ou um Client Component de hidratação
useEffect(() => {
useWishlistStore.getState().hydrate();
}, []);
Se o usuário não estiver logado, hydrate() falha silenciosamente e o estado local é mantido.
4. useAdminUIStore
Gerencia as preferências de interface do painel administrativo.
Arquivo: store/admin-ui-store.ts
Chave no localStorage: "admin-ui-preferences"
Estado e API
interface AdminUIState {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
}
Uso no AdminSidebar
"use client";
import { useAdminUIStore } from "@/store/admin-ui-store";
export default function AdminSidebar() {
const { sidebarCollapsed, toggleSidebar } = useAdminUIStore();
return (
<aside className={sidebarCollapsed ? "w-16" : "w-64"}>
<button onClick={toggleSidebar}>
{/* ícone de menu */}
</button>
{/* links de navegação */}
</aside>
);
}
A preferência é salva imediatamente no localStorage e restaurada no próximo acesso. Isso é desejável — diferente de isOpen no carrinho, o estado collapsed da sidebar é uma preferência do operador, não um estado transiente.
5. Hidratação e SSR
O Problema
O Next.js renderiza o HTML inicial no servidor. O servidor não tem acesso ao localStorage. Se um componente lê o estado de um store Zustand durante a renderização (SSR), o estado será o inicial ([], false, null). Quando o JavaScript carrega no cliente e o store é hidratado com os dados do localStorage, o DOM pode divergir do HTML estático — causando um hydration mismatch.
Solução com partialize
A solução principal adotada pelo projeto é não persistir campos de estado transiente que afetam a estrutura do DOM. Por exemplo, isOpen do carrinho é excluído da persistência:
partialize: (state) => ({
items: state.items, // ok persistir — afeta apenas contagem de itens
shipping: state.shipping,
coupon: state.coupon,
// isOpen: excluído — afetaria se a gaveta renderiza aberta ou fechada
}),
Padrão de Mount Guard
Para componentes que precisam ler dados do localStorage e renderizar algo diferente com base nisso (ex.: contador de itens no header), use um mount guard para evitar mismatch:
"use client";
import { useState, useEffect } from "react";
import { useCartStore } from "@/store/cart";
export default function CartIconBadge() {
const [mounted, setMounted] = useState(false);
const itemCount = useCartStore((state) => state.itemCount());
useEffect(() => {
setMounted(true);
}, []);
// Renderiza o mesmo HTML do servidor até o cliente estar pronto
if (!mounted) return <span className="cart-badge">0</span>;
return <span className="cart-badge">{itemCount}</span>;
}
Após o useEffect, o componente re-renderiza com o valor real do localStorage, mas como o cliente já assumiu o controle do DOM nesse ponto, não há conflito com o HTML do servidor.
6. Quando Usar Estado Local vs Zustand vs RSC
| Situação | Solução recomendada |
|---|---|
| Estado que afeta apenas um componente (ex.: accordion aberto/fechado) | useState local |
| Dados de formulário | useState ou react-hook-form |
| Estado compartilhado entre componentes distantes na árvore | Zustand |
| Estado que precisa sobreviver a navegações de página | Zustand com persist |
| Dado de banco que não muda por ação do usuário | RSC (Server Component) |
| Dado de banco que precisa de revalidação após mutação | RSC + revalidatePath / revalidateTag |
| Preferências do usuário armazenadas localmente | Zustand com persist |
| Carrinho de compras | useCartStore (já existente) |
| Lista de favoritos | useWishlistStore (já existente) |
| Estado de UI do admin (sidebar) | useAdminUIStore (já existente) |
Regra de ouro
Antes de criar um novo store Zustand, pergunte:
- Esse estado precisa ser compartilhado entre componentes que não são pai/filho? Se não, use
useState. - Esse estado precisa sobreviver a recarregamentos de página? Se não, considere
useStateno layout pai. - Esse estado vem do banco de dados? Se sim, prefira RSC + props.
7. Padrões de Uso
Adicionar item ao carrinho
"use client";
import { useCartStore } from "@/store/cart";
import type { CartProduct } from "@/store/cart";
export default function AddToCartButton({ product }: { product: CartProduct }) {
const addItem = useCartStore((state) => state.addItem);
return (
<button onClick={() => addItem(product, 1)}>
Adicionar ao carrinho
</button>
);
}
Remover item do carrinho
const removeItem = useCartStore((state) => state.removeItem);
// Produto sem variante
removeItem(product.id);
// Produto com variante
removeItem(product.id, product.variantId);
Aplicar cupom após validação
const applyCoupon = useCartStore((state) => state.applyCoupon);
async function handleApplyCoupon(code: string) {
const res = await fetch(`/api/coupons/validate?code=${code}`);
if (!res.ok) {
toast.error("Cupom inválido ou expirado");
return;
}
const coupon = await res.json();
applyCoupon({ id: coupon.id, code: coupon.code, discount: coupon.discount });
toast.success(`Cupom aplicado: -R$ ${coupon.discount.toFixed(2)}`);
}
Exibir total do pedido no checkout
"use client";
import { useCartStore } from "@/store/cart";
export default function OrderSummary() {
// Assinar apenas o que será usado — evita re-renders desnecessários
const items = useCartStore((state) => state.items);
const total = useCartStore((state) => state.total());
const shippingCost = useCartStore((state) => state.shippingCost());
const coupon = useCartStore((state) => state.coupon);
const orderTotal = useCartStore((state) => state.orderTotal());
return (
<div>
<p>Subtotal: R$ {total.toFixed(2)}</p>
<p>Frete: {shippingCost === 0 ? "Grátis" : `R$ ${shippingCost.toFixed(2)}`}</p>
{coupon && <p>Desconto ({coupon.code}): -R$ {coupon.discount.toFixed(2)}</p>}
<p>Total: R$ {orderTotal.toFixed(2)}</p>
</div>
);
}
Verificar e alternar favorito
"use client";
import { useWishlistStore } from "@/store/wishlist";
import { Heart } from "lucide-react";
export default function WishlistButton({ productId }: { productId: string }) {
const isFavorite = useWishlistStore((state) => state.isFavorite(productId));
const toggle = useWishlistStore((state) => state.toggle);
return (
<button onClick={() => toggle(productId)}>
<Heart
className={isFavorite ? "fill-red-500 text-red-500" : "text-gray-400"}
/>
</button>
);
}
Limpar carrinho no logout
// Fora de componente React — sem hook
import { useCartStore } from "@/store/cart";
import { useWishlistStore } from "@/store/wishlist";
async function logout() {
await fetch("/api/auth/logout", { method: "POST" });
useCartStore.getState().clearCart();
// wishlist local permanece — ela é pública (favoritos são uma preferência pessoal)
router.push("/auth/login");
}