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");
}