Pular para o conteúdo principal

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 dadoOnde viveTecnologia
Dados do banco (produtos, pedidos, usuário)ServidorReact Server Components + Prisma
Estado de UI do carrinhoClienteZustand + localStorage
Lista de favoritosCliente + APIZustand com sincronização otimista
Preferências de UI do adminClienteZustand + localStorage
Estado de formuláriosComponente localuseState ou react-hook-form
Estado de UI efêmero (modal aberto, tab ativa)Componente localuseState

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​

ActionComportamento
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çãoRetorno
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çãoSolução recomendada
Estado que afeta apenas um componente (ex.: accordion aberto/fechado)useState local
Dados de formuláriouseState ou react-hook-form
Estado compartilhado entre componentes distantes na árvoreZustand
Estado que precisa sobreviver a navegações de páginaZustand com persist
Dado de banco que não muda por ação do usuárioRSC (Server Component)
Dado de banco que precisa de revalidação após mutaçãoRSC + revalidatePath / revalidateTag
Preferências do usuário armazenadas localmenteZustand com persist
Carrinho de comprasuseCartStore (já existente)
Lista de favoritosuseWishlistStore (já existente)
Estado de UI do admin (sidebar)useAdminUIStore (já existente)

Regra de ouro​

Antes de criar um novo store Zustand, pergunte:

  1. Esse estado precisa ser compartilhado entre componentes que não são pai/filho? Se não, use useState.
  2. Esse estado precisa sobreviver a recarregamentos de página? Se não, considere useState no layout pai.
  3. 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");
}