📚 Módulo 08: Frontend - Carrinho com Zustand e Server Actions
Neste módulo, daremos interatividade total à TecLoja 04. No projeto com React e Vite utilizamos Custom Hooks e Context API para o Carrinho. Aqui, adotaremos o Zustand, a biblioteca de gerenciamento de estado client-side predileta da comunidade Next.js devido ao seu peso mínimo e ausência de boilerplate.
Para fechar o pedido de compra, utilizaremos a mágica das Server Actions do Next.js para enviar a transação ao NestJS sem precisarmos criar manualmente rotas de API adicionais.
🐻 1. O Zustand: Estado Simples e Poderoso
stateDiagram-v2
[*] --> Carrinho
Carrinho --> ZustandStore : Client Component clica em Comprar
ZustandStore --> LocalStorage : Middleware persist() nativo
ZustandStore --> ReactComponents : Atualiza UI reativamente
state ZustandStore {
itens
adicionar()
remover()
totalItens()
}
Instale o Zustand:
npm install zustand
Crie o arquivo em src/store/useCartStore.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface ProdutoCart {
id: number;
nome: string;
preco: number;
quantidade: number;
}
interface CartState {
itens: ProdutoCart[];
adicionar: (produto: ProdutoCart) => void;
remover: (id: number) => void;
limpar: () => void;
obterValorTotal: () => number;
}
// O middleware "persist" salva os dados automaticamente no localStorage para não perder o carrinho no F5
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
itens: [],
adicionar: (produto) => set((state) => {
const existe = state.itens.find(i => i.id === produto.id);
if (existe) {
return { itens: state.itens.map(i => i.id === produto.id ? { ...i, quantidade: i.quantidade + 1 } : i) };
}
return { itens: [...state.itens, { ...produto, quantidade: 1 }] };
}),
remover: (id) => set((state) => ({
itens: state.itens.filter(i => i.id !== id)
})),
limpar: () => set({ itens: [] }),
obterValorTotal: () => {
return get().itens.reduce((total, item) => total + (item.preco * item.quantidade), 0);
}
}),
{ name: 'tecloja_cart' }
)
);
O Componente de Botão de Compra (Client Component)
Lembra da nossa vitrine (src/app/page.tsx) que era um Server Component assíncrono? Não podemos colocar onClick lá. A solução do Next.js é extrair o botão para um pequeno Client Component isolado e importá-lo!
Crie src/components/BotaoComprar.tsx:
"use client"; // Esse arquivo vai para o navegador!
import { useCartStore } from '../store/useCartStore';
export default function BotaoComprar({ produto }: { produto: any }) {
const adicionar = useCartStore(state => state.adicionar);
return (
<button
onClick={() => adicionar({ id: produto.id, nome: produto.nome, preco: parseFloat(produto.preco), quantidade: 1 })}
disabled={produto.estoque <= 0}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 text-white font-medium py-2 rounded-lg transition-colors mt-4"
>
Adicionar ao Carrinho
</button>
);
}
⚡ 2. Finalizando o Pedido com Server Actions
O checkout precisa se comunicar com a nossa API NestJS (Porta 3000). Em vez de criarmos chamadas complexas com Axios ou Fetch no navegador (expondo lógica para o cliente), o Next 14 lançou as Server Actions.
Server Actions são funções assíncronas declaradas no front-end, mas que só rodam de forma escondida no back-end (Node.js)!
Crie o arquivo src/lib/actions.ts:
"use server"; // A MÁGICA: Esta diretiva avisa o Next.js que esta função NÃO deve ir para o navegador
import { cookies } from 'next/headers';
// Esta função será chamada pelo componente do Carrinho (no Browser)
// Mas o código dela rodará isolado no servidor do Vercel
export async function realizarCheckoutAction(itens: { produtoId: number, quantidade: number }[]) {
// Como estamos no Server-Side, podemos pegar o token seguro com facilidade!
const token = cookies().get('tecloja_token')?.value;
const res = await fetch('http://localhost:3000/pedidos/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }) // Se houver token, passa para a API
},
body: JSON.stringify({
usuarioId: 1, // Semeado didático
itens: itens
})
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || 'Erro ao processar o faturamento no NestJS');
}
return await res.json(); // Pedido confirmado!
}
🛒 3. A Tela do Carrinho e a Chamada da Action
Crie a página em src/app/carrinho/page.tsx:
"use client";
import React, { useState } from 'react';
import { useCartStore } from '../../store/useCartStore';
import { realizarCheckoutAction } from '../../lib/actions';
import Link from 'next/link';
export default function CarrinhoPage() {
const { itens, remover, limpar, obterValorTotal } = useCartStore();
const [carregando, setCarregando] = useState(false);
const [sucesso, setSucesso] = useState(false);
const [erro, setErro] = useState('');
const handleCheckout = async () => {
setCarregando(true);
setErro('');
try {
// O Next.js cria uma chamada RPC automática para executar a Action no Node.js!
const payload = itens.map(i => ({ produtoId: i.id, quantidade: i.quantidade }));
await realizarCheckoutAction(payload);
limpar();
setSucesso(true);
} catch (e: any) {
setErro(e.message);
} finally {
setCarregando(false);
}
};
if (sucesso) return (
<div className="min-h-screen flex items-center justify-center bg-slate-900 text-white">
<div className="bg-slate-800 p-10 rounded-2xl text-center">
<h2 className="text-3xl text-emerald-400 font-bold mb-4">🎉 Compra Confirmada!</h2>
<Link href="/" className="text-blue-400 hover:underline">Voltar para o Catálogo</Link>
</div>
</div>
);
return (
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
<h1 className="text-3xl font-bold mb-8">🛒 Carrinho</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="md:col-span-2 space-y-4">
{itens.length === 0 ? <p>Carrinho Vazio</p> : itens.map(item => (
<div key={item.id} className="bg-slate-800 p-4 rounded-xl flex justify-between items-center border border-slate-700">
<div>
<p className="font-semibold text-lg">{item.nome}</p>
<p className="text-slate-400 text-sm">Quantidade: {item.quantidade}</p>
</div>
<div className="flex items-center gap-4">
<span className="font-bold text-cyan-400">R$ {item.preco * item.quantidade}</span>
<button onClick={() => remover(item.id)} className="text-rose-400 hover:text-rose-300">Remover</button>
</div>
</div>
))}
</div>
<div className="bg-slate-800 p-6 rounded-xl border border-slate-700 h-fit">
<h3 className="text-xl font-bold mb-4">Resumo do Pedido</h3>
<p className="flex justify-between text-lg mb-6"><span>Total:</span> <span className="font-bold text-cyan-400">R$ {obterValorTotal()}</span></p>
{erro && <p className="text-red-400 text-sm mb-4">{erro}</p>}
<button
onClick={handleCheckout}
disabled={itens.length === 0 || carregando}
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-600 text-white font-bold py-3 rounded-lg transition-colors"
>
{carregando ? 'Processando Transação...' : 'Confirmar Compra'}
</button>
</div>
</div>
</div>
);
}
✅ Pré-Requisitos deste Módulo
Antes de passar para o último módulo de DevOps, testes unitários no NestJS, Dockerfiles e deploys automatizados, certifique-se de que:
- A base do Route Handler do BFF e o middleware de segurança de cookies foram concluídos no Módulo 07.
- A API NestJS está rodando localmente (com banco Postgres) para responder às chamadas de faturamento da Server Action.
🤔 Por que fizemos assim?
- Por que escolher o Zustand em vez de Context API ou Redux para gerenciar o estado do carrinho de compras? O Zustand é uma biblioteca de gerenciamento de estado extremamente leve (menos de 2KB), simples e sem boilerplate (dispensa a criação de Providers, Actions e Reducers complexos). Ele permite declarar um estado global reativo fora da árvore do React, o que evita re-renderizações desnecessárias de componentes não relacionados e traz facilidades nativas para salvar o carrinho no
localStorageusando o middlewarepersist. - Por que utilizar o padrão de Server Actions (
"use server") para efetuar a chamada de faturamento à API? O Next.js gerencia de forma transparente a ponte de comunicação (RPC) do cliente para o servidor. Codificar o faturamento em uma Server Action garante que o código execute exclusivamente no servidor Node.js. Isso oculta a URL real da API NestJS e permite ler de forma direta e segura o cookie de token de loginHttpOnlyno servidor Next, evitando a exposição de chaves ou segredos de ambiente no bundle de JavaScript enviado ao navegador do usuário. - Por que isolar o botão de compras (
BotaoComprar.tsx) como Client Component em vez de converter toda a página do Catálogo? Trata-se de uma boa prática essencial para maximizar a performance e SEO (arquitetura baseada em ilhas). O catálogo é carregado no servidor como React Server Component (RSC), eliminando o download de JavaScript pesado pelo navegador. Separar apenas as pequenas pontas interativas que requerem eventos de clique ("use client") garante que a página inicial seja renderizada instantaneamente pelo browser enquanto apenas o código mínimo de interação é hidratado no cliente.
🔍 Checkpoint
- Estado Persistido: Adicione produtos ao carrinho no catálogo. Recarregue a página (F5) e confirme se os dados continuam persistidos na tela e na chave
tecloja_cartdolocalStoragedo navegador. - Execução da Action: Clique em “Confirmar Compra” na página de carrinho. Valide nas ferramentas de rede do navegador que a chamada disparada não envia cabeçalhos ou dados visíveis para a API NestJS, mas sim para o servidor interno do Next.
- Consistência Financeira: Confirme no banco de dados relacional que os estoques correspondentes aos itens comprados foram atômicamente deduzidos após a conclusão do pedido.
⚠️ Erros Comuns
| Erro | Causa | Solução |
|---|---|---|
Erro Hydration failed ao carregar dados do Zustand na inicialização da página |
O servidor gerou o HTML vazio do carrinho e o cliente tentou injetar os dados salvos do localStorage na montagem física inicial, conflitando as estruturas. | Crie um estado local mounted utilizando useState(false) e useEffect(() => setMounted(true), []) no React, e exiba os componentes do Zustand somente se mounted for verdadeiro. |
A Server Action falha retornando erro Cookies can only be read from... |
O desenvolvedor tentou ler ou injetar cabeçalhos de cookies dentro de um componente marcado como Client Component. | Certifique-se de que a leitura de cookies (cookies().get(...)) ocorre estritamente dentro de funções Server Components, Route Handlers ou arquivos dedicados declarados com "use server" no topo. |
| O faturamento falha alegando falta de autorização (401 Unauthorized) | O cookie tecloja_token não foi enviado corretamente nas requisições da Server Action para a API NestJS. |
Verifique se as credenciais foram anexadas de forma automática na chamada interna do fetch usando o cabeçalho Authorization: Bearer <token> extraído do cookie ativo do BFF. |
🏁 Conclusão
Sensacional! Utilizamos o Zustand para criar uma persistência de carrinho ultraleve no lado do cliente e fechamos nossa arquitetura com Chave de Ouro invocando uma Server Action. Com as Server Actions, o Next.js lida com a ponte Cliente-Servidor (RPC) de forma invisível, deixando o código absurdamente limpo e isento de “fetchs” verbosos e estados de loading pesados.
No Módulo 09 final, abordaremos o ápice da engenharia: A conteinerização Docker otimizada e o Deploy Automatizado CI/CD para a arquitetura Full-Stack corporativa.