Javascript

Revisão + Projeto Final: SPA Completa Já leu

32 min de leitura

Revisão + Projeto Final: SPA Completa
Chegamos ao fim de mais uma etapa. Em cinco artigos você aprendeu React do zero — componentes, hooks, estado global com Zustand, dados do servidor com React Query e navegação com React Router. Agora é hora de unir tudo e

Chegamos ao fim de mais uma etapa. Em cinco artigos você aprendeu React do zero — componentes, hooks, estado global com Zustand, dados do servidor com React Query e navegação com React Router. Agora é hora de unir tudo em um único projeto coeso.

O projeto final é uma SPA de gestão de tarefas e produtos — uma aplicação real com autenticação, múltiplas páginas, dados de uma API, e toda a qualidade que aprendemos nos módulos anteriores. Antes de começar o código, vamos revisar os conceitos do módulo.


Revisão


React — a base

O React é uma biblioteca para construção de interfaces baseada em três ideias fundamentais. Primeira: a UI é uma função do estado — dados entram, JSX sai. Segunda: quando o estado muda, o React recalcula o JSX e atualiza apenas o que mudou no DOM. Terceira: a interface é composta por componentes pequenos e reutilizáveis.

// A ideia central do React em uma linha:
// UI = f(estado)
// Mude o estado → React atualiza a UI automaticamente

function Componente({ props }) {
  const [estado, setEstado] = useState(valorInicial);
  // Efeitos colaterais: busca de dados, timers, eventos globais
  useEffect(() => { /* executa após renderizar */ }, [dependencias]);
  return <JSX />;
}

Hooks — o coração da lógica

Os hooks são a forma de adicionar comportamento aos componentes funcionais. Cada hook tem um propósito específico, e escolher o certo faz toda a diferença na clareza e performance do código.

// useState  → estado simples que causa re-render quando muda
// useReducer → estado complexo com múltiplas ações inter-relacionadas
// useContext → compartilha dados sem passar props por vários níveis
// useRef     → referência a elemento DOM ou valor que não causa re-render
// useMemo    → memoriza resultado de cálculo custoso
// useCallback → memoriza referência de função (evita re-render de filhos)
// useEffect  → sincroniza com mundo externo (API, timers, eventos)

// Hooks customizados encapsulam lógica reutilizável
function useDebounce(valor, delay) { /* ... */ }
function useFetch(url) { /* ... */ }
function useLocalStorage(chave, inicial) { /* ... */ }

Estado global e dados do servidor

Uma distinção importante que vale repetir: estado de UI (autenticação, tema, carrinho) e estado de servidor (dados de APIs) têm necessidades completamente diferentes. Misturá-los no mesmo lugar causa complexidade desnecessária.

Zustand → estado de UI global
  ✓ Autenticação (token, usuário)
  ✓ Tema (claro/escuro)
  ✓ Carrinho de compras
  ✓ Notificações, modais globais

React Query → estado de servidor
  ✓ Cache automático com staleTime configurável
  ✓ Revalidação quando a janela ganha foco
  ✓ Loading, error e success states automáticos
  ✓ Invalidação após mutações
  ✓ Update otimista

React Router

O React Router transforma nossa aplicação de um componente único em múltiplas "páginas" sem recarregar o navegador. A URL torna-se a fonte da verdade para o estado de navegação.

// Estrutura em camadas: rotas protegem outras rotas
<Routes>
  <Route element={<RotaPublica />}>      {/* se logado, redireciona */}
    <Route path="/login" element={<Login />} />
  </Route>
  <Route element={<RotaProtegida />}>    {/* se não logado, vai para /login */}
    <Route element={<Layout />}>         {/* layout compartilhado */}
      <Route path="/dashboard" element={<Dashboard />} />
    </Route>
  </Route>
</Routes>

O projeto — SPA de Gestão

A aplicação que vamos construir tem as seguintes características e páginas:

Páginas públicas: Login e Cadastro — acessíveis sem autenticação. Se o usuário já estiver logado, é redirecionado ao dashboard.

Páginas protegidas: Dashboard com resumo geral, lista de produtos com filtros, detalhe de produto, lista de tarefas, e perfil do usuário.

Tecnologias integradas: React com Vite, React Router para navegação, Zustand para autenticação, React Query para dados, hooks customizados para lógica reutilizável.

Vamos construir do setup até as páginas principais, com foco em explicar cada decisão de arquitetura.


Setup do projeto

Começamos criando o projeto com Vite e instalando todas as dependências necessárias de uma vez. Note que separamos dependências de produção das de desenvolvimento.

npm create vite@latest spa-gestao -- --template react
cd spa-gestao

# Dependências de produção — vão para o bundle final
npm install react-router-dom zustand @tanstack/react-query

# Dependências de desenvolvimento — não vão para o bundle
npm install -D @tanstack/react-query-devtools

A estrutura de pastas que vamos adotar reflete a separação de responsabilidades. Cada pasta tem um papel claro, o que facilita encontrar código e adicionar funcionalidades sem criar bagunça.

src/
├── components/        ← componentes reutilizáveis (UI)
│   ├── Layout.jsx
│   ├── RotaProtegida.jsx
│   └── ui/            ← botões, inputs, cards genéricos
├── hooks/             ← hooks customizados
│   ├── useDebounce.js
│   └── useLocalStorage.js
├── pages/             ← uma pasta por página da aplicação
│   ├── Login.jsx
│   ├── Dashboard.jsx
│   ├── Produtos.jsx
│   ├── DetalheProduto.jsx
│   ├── Tarefas.jsx
│   └── NaoEncontrado.jsx
├── services/          ← funções de chamada à API (sem React)
│   ├── api.js         ← configuração base (URL, headers)
│   ├── authService.js
│   ├── produtoService.js
│   └── tarefaService.js
├── stores/            ← stores do Zustand
│   └── authStore.js
└── App.jsx

Camada de serviços — separando a API do React

Antes de qualquer componente, criamos a camada de serviços. Esta decisão de arquitetura é fundamental: as funções de chamada à API não devem morar dentro dos componentes. Isso permite reutilizá-las, testá-las isoladamente e trocar a implementação sem tocar na UI.

O arquivo api.js centraliza a configuração base. Todas as outras funções de serviço usam esta base, garantindo que a URL e os headers sejam sempre consistentes.

// src/services/api.js
// Este módulo exporta uma função "fetcher" configurada que:
// 1. Usa a URL base da variável de ambiente (ou localhost como fallback)
// 2. Injeta automaticamente o token de autenticação se existir
// 3. Lança erros HTTP com mensagens legíveis

const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';

// Função auxiliar que lê o token do localStorage diretamente
// (sem depender do Zustand, para evitar dependência circular)
function obterToken() {
  try {
    // O Zustand com persist salva no formato { state: { token: "..." } }
    const storage = localStorage.getItem('auth-storage');
    if (!storage) return null;
    const parsed = JSON.parse(storage);
    return parsed?.state?.token || null;
  } catch {
    return null;
  }
}

// Função principal de requisição — envolve o fetch nativo com configurações padrão
export async function api(endpoint, opcoes = {}) {
  const token = obterToken();

  // Monta os headers base, adicionando Authorization apenas se o token existir
  const headers = {
    'Content-Type': 'application/json',
    ...(token && { Authorization: `Bearer ${token}` }),
    ...opcoes.headers, // permite sobrescrever headers específicos
  };

  const resposta = await fetch(`${BASE_URL}${endpoint}`, {
    ...opcoes,
    headers,
  });

  // fetch não lança erro em respostas 4xx/5xx — precisamos checar manualmente
  if (!resposta.ok) {
    // Tenta extrair mensagem de erro do corpo da resposta
    let mensagemErro = `Erro ${resposta.status}`;
    try {
      const corpo = await resposta.json();
      mensagemErro = corpo.erro || corpo.message || mensagemErro;
    } catch {
      // Se não conseguir parsear o corpo, usa a mensagem padrão
    }
    throw new Error(mensagemErro);
  }

  // Respostas 204 (No Content) não têm corpo
  if (resposta.status === 204) return null;

  return resposta.json();
}

Com a função base pronta, os serviços específicos ficam extremamente simples e focados apenas na lógica de domínio.

// src/services/produtoService.js
// Cada função representa uma operação de negócio clara e testável
import { api } from './api.js';

// Lista produtos com filtros e paginação
// Os filtros são um objeto { categoria, pagina, por_pagina, busca }
export async function listarProdutos(filtros = {}) {
  // URLSearchParams converte o objeto em query string de forma segura
  // Ex: { categoria: 'eletronicos', pagina: 2 } → "categoria=eletronicos&pagina=2"
  const params = new URLSearchParams(
    // Remove filtros com valores vazios para não poluir a URL
    Object.fromEntries(
      Object.entries(filtros).filter(([, v]) => v !== '' && v !== undefined)
    )
  );
  return api(`/produtos?${params}`);
}

export async function buscarProduto(id) {
  return api(`/produtos/${id}`);
}

export async function criarProduto(dados) {
  return api('/produtos', {
    method: 'POST',
    body: JSON.stringify(dados),
  });
}

export async function atualizarProduto(id, dados) {
  return api(`/produtos/${id}`, {
    method: 'PUT',
    body: JSON.stringify(dados),
  });
}

export async function removerProduto(id) {
  return api(`/produtos/${id}`, { method: 'DELETE' });
}
// src/services/tarefaService.js
import { api } from './api.js';

export async function listarTarefas(filtros = {}) {
  const params = new URLSearchParams(
    Object.fromEntries(
      Object.entries(filtros).filter(([, v]) => v !== '' && v !== undefined)
    )
  );
  return api(`/tarefas?${params}`);
}

export async function criarTarefa(dados) {
  return api('/tarefas', {
    method: 'POST',
    body: JSON.stringify(dados),
  });
}

export async function atualizarTarefa(id, dados) {
  return api(`/tarefas/${id}`, {
    method: 'PUT',
    body: JSON.stringify(dados),
  });
}

export async function removerTarefa(id) {
  return api(`/tarefas/${id}`, { method: 'DELETE' });
}
// src/services/authService.js
import { api } from './api.js';

export async function loginService(email, senha) {
  // Esta rota não precisa de token — o api() já lida com isso
  // (obterToken() retorna null se não há token)
  return api('/auth/login', {
    method: 'POST',
    body: JSON.stringify({ email, senha }),
  });
}

export async function registrarService(nome, email, senha) {
  return api('/auth/registrar', {
    method: 'POST',
    body: JSON.stringify({ nome, email, senha }),
  });
}

export async function buscarPerfil() {
  return api('/auth/eu');
}

Store de autenticação

O store de autenticação é o único estado verdadeiramente global da aplicação — o token e o usuário precisam estar acessíveis em qualquer componente, desde o header da navbar até o middleware de rotas protegidas.

Usamos o middleware persist do Zustand para salvar o token no localStorage automaticamente. Isso garante que o usuário continue logado mesmo após fechar e reabrir o navegador.

// src/stores/authStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { loginService, registrarService, buscarPerfil } from '../services/authService.js';

const useAuthStore = create(
  // persist envolve o store e salva/restaura do localStorage automaticamente
  persist(
    (set, get) => ({
      // ── Estado inicial ──────────────────────────
      usuario: null,
      token: null,
      carregando: false,
      erro: null,

      // ── Ações ────────────────────────────────────

      login: async (email, senha) => {
        // Inicia o loading e limpa erros anteriores antes de qualquer coisa
        set({ carregando: true, erro: null });
        try {
          const { token, usuario } = await loginService(email, senha);
          // Salva token e usuário — persist captura esta mudança e salva no localStorage
          set({ token, usuario, carregando: false });
          return { sucesso: true };
        } catch (erro) {
          // Salva a mensagem de erro para exibir no formulário
          set({ carregando: false, erro: erro.message });
          return { sucesso: false, erro: erro.message };
        }
      },

      registrar: async (nome, email, senha) => {
        set({ carregando: true, erro: null });
        try {
          const { token, usuario } = await registrarService(nome, email, senha);
          set({ token, usuario, carregando: false });
          return { sucesso: true };
        } catch (erro) {
          set({ carregando: false, erro: erro.message });
          return { sucesso: false, erro: erro.message };
        }
      },

      logout: () => {
        // Limpa tudo — persist remove do localStorage automaticamente
        set({ usuario: null, token: null, erro: null });
      },

      // Atualiza apenas os dados do usuário (sem alterar o token)
      // Útil quando o usuário edita o perfil
      atualizarUsuario: (novosDados) => {
        set((state) => ({
          usuario: { ...state.usuario, ...novosDados },
        }));
      },

      limparErro: () => set({ erro: null }),
    }),
    {
      name: 'auth-storage', // chave no localStorage
      // partialize define QUAIS partes do estado persistir
      // Nunca persistimos 'carregando' ou 'erro' — são estados transitórios
      partialize: (state) => ({
        token: state.token,
        usuario: state.usuario,
      }),
    }
  )
);

export default useAuthStore;

Configuração do React Query e roteamento

O ponto de entrada main.jsx é onde configuramos os provedores globais. A ordem importa: o QueryClientProvider precisa estar dentro do BrowserRouter — ou fora, mas nunca entre componentes de rota.

// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
import './index.css';

// Configuração global do React Query
// Estas opções se aplicam a todas as queries da aplicação
// e podem ser sobrescritas individualmente em cada useQuery()
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Dados são considerados "frescos" por 5 minutos
      // Durante este período, o React Query não revalida mesmo ao focar a janela
      staleTime: 1000 * 60 * 5,

      // Dados permanecem em cache por 10 minutos mesmo sem observadores
      // (componentes que usem a query)
      gcTime: 1000 * 60 * 10,

      // Tenta novamente 2 vezes em caso de erro antes de desistir
      retry: 2,

      // Revalida dados ao usuário voltar para a aba/janela
      refetchOnWindowFocus: true,
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    {/*
      StrictMode renderiza os componentes duas vezes em desenvolvimento
      para detectar efeitos colaterais não intencionais — é normal ver
      os useEffect rodando duas vezes em dev
    */}
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
      {/* DevTools — aparece como um ícone flutuante apenas em desenvolvimento */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </React.StrictMode>
);

Rotas protegidas

As rotas protegidas são componentes "guardião" — elas verificam uma condição e ou renderizam o filho () ou redirecionam. Este padrão é elegante porque separa a lógica de autorização do conteúdo das páginas.

// src/components/RotaProtegida.jsx
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import useAuthStore from '../stores/authStore.js';

// Guarda para usuários que precisam estar logados
// Se não estiver logado: vai para /login guardando para onde tentou ir
export function RotaProtegida() {
  const token = useAuthStore((state) => state.token);
  const location = useLocation();

  if (!token) {
    return (
      // replace: true evita que /login apareça no histórico de navegação
      // state: { de: ... } permite redirecionar de volta após o login
      <Navigate
        to="/login"
        state={{ de: location.pathname }}
        replace
      />
    );
  }

  // Outlet renderiza o componente filho correspondente à rota atual
  return <Outlet />;
}

// Guarda para páginas que só fazem sentido sem login (login, cadastro)
// Se já estiver logado: vai direto ao dashboard
export function RotaPublica() {
  const token = useAuthStore((state) => state.token);

  if (token) {
    return <Navigate to="/dashboard" replace />;
  }

  return <Outlet />;
}

Layout principal

O layout é o "esqueleto" que envolve todas as páginas protegidas. Ele é renderizado uma vez e o Outlet troca apenas o conteúdo central conforme a rota muda — o header e o sidebar não são re-montados a cada navegação.

// src/components/Layout.jsx
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import useAuthStore from '../stores/authStore.js';

// Função auxiliar para construir a className do NavLink dinamicamente
// O React Router chama esta função com { isActive: true/false }
function classesNavLink({ isActive }) {
  return `nav-link ${isActive ? 'nav-link--ativo' : ''}`;
}

export default function Layout() {
  const usuario = useAuthStore((state) => state.usuario);
  const logout = useAuthStore((state) => state.logout);
  const navigate = useNavigate();

  function handleLogout() {
    logout();
    // Após limpar o estado, redireciona para login
    // replace: true para não acumular /login no histórico
    navigate('/login', { replace: true });
  }

  return (
    <div className="layout">
      <aside className="sidebar">
        <div className="sidebar__logo">
          <h2>⚡ Gestão</h2>
        </div>

        <nav className="sidebar__nav">
          {/* "end" garante que "/" só fica ativo em "/" exato, não em "/dashboard" */}
          <NavLink to="/dashboard" className={classesNavLink}>
            📊 Dashboard
          </NavLink>
          <NavLink to="/produtos" className={classesNavLink}>
            📦 Produtos
          </NavLink>
          <NavLink to="/tarefas" className={classesNavLink}>
            ✅ Tarefas
          </NavLink>
          <NavLink to="/perfil" className={classesNavLink}>
            👤 Perfil
          </NavLink>
        </nav>

        <div className="sidebar__footer">
          {/* Exibe o nome do usuário logado vindo do Zustand */}
          <span className="usuario-nome">{usuario?.nome}</span>
          <button onClick={handleLogout} className="btn-logout">
            Sair
          </button>
        </div>
      </aside>

      <main className="conteudo-principal">
        {/*
          Outlet é o "buraco" onde o componente filho da rota atual é renderizado
          Dashboard, Produtos, Tarefas e Perfil aparecem aqui
        */}
        <Outlet />
      </main>
    </div>
  );
}

App.jsx — mapa de rotas

O App.jsx é o ponto central de navegação. Ele não contém lógica — apenas declara a estrutura de rotas. Lazy loading em todas as páginas garante que o bundle inicial seja pequeno.

// src/App.jsx
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout.jsx';
import { RotaProtegida, RotaPublica } from './components/RotaProtegida.jsx';

// lazy() faz code splitting automático — cada página vira um chunk separado
// O chunk só é baixado quando o usuário navega para aquela rota pela primeira vez
const Login         = lazy(() => import('./pages/Login.jsx'));
const Cadastro      = lazy(() => import('./pages/Cadastro.jsx'));
const Dashboard     = lazy(() => import('./pages/Dashboard.jsx'));
const Produtos      = lazy(() => import('./pages/Produtos.jsx'));
const DetalheProduto = lazy(() => import('./pages/DetalheProduto.jsx'));
const Tarefas       = lazy(() => import('./pages/Tarefas.jsx'));
const Perfil        = lazy(() => import('./pages/Perfil.jsx'));
const NaoEncontrado = lazy(() => import('./pages/NaoEncontrado.jsx'));

// Fallback exibido enquanto o chunk da página está sendo baixado
function Carregando() {
  return (
    <div className="pagina-carregando">
      <div className="spinner" aria-label="Carregando página" />
    </div>
  );
}

export default function App() {
  return (
    // Suspense captura qualquer lazy() abaixo na árvore que ainda esteja carregando
    <Suspense fallback={<Carregando />}>
      <Routes>

        {/* Rota raiz — redireciona para dashboard */}
        <Route path="/" element={<Navigate to="/dashboard" replace />} />

        {/* ── Rotas públicas (só acessa se NÃO estiver logado) ── */}
        <Route element={<RotaPublica />}>
          <Route path="/login"    element={<Login />} />
          <Route path="/cadastro" element={<Cadastro />} />
        </Route>

        {/* ── Rotas protegidas (só acessa SE estiver logado) ─── */}
        <Route element={<RotaProtegida />}>
          {/*
            Layout é pai de todas as rotas protegidas
            Ele renderiza o sidebar + header uma vez
            O Outlet dentro do Layout troca o conteúdo conforme a rota
          */}
          <Route element={<Layout />}>
            <Route path="/dashboard"        element={<Dashboard />} />
            <Route path="/produtos"         element={<Produtos />} />
            <Route path="/produtos/:id"     element={<DetalheProduto />} />
            <Route path="/tarefas"          element={<Tarefas />} />
            <Route path="/perfil"           element={<Perfil />} />
          </Route>
        </Route>

        {/* Qualquer rota não mapeada cai aqui */}
        <Route path="*" element={<NaoEncontrado />} />

      </Routes>
    </Suspense>
  );
}

Hooks customizados reutilizáveis

Antes de construir as páginas, criamos os hooks que elas vão usar. Centralizar lógica de acesso à API em hooks customizados é uma das melhores práticas do ecossistema React — mantém os componentes focados apenas em renderização.

// src/hooks/useDebounce.js
// Este hook atrasa a atualização de um valor por um período configurável
// Essencial para campos de busca — evita uma requisição à API por tecla digitada
import { useState, useEffect } from 'react';

export function useDebounce(valor, delay = 500) {
  const [valorAtrasado, setValorAtrasado] = useState(valor);

  useEffect(() => {
    // Agenda a atualização para depois do delay
    const timer = setTimeout(() => {
      setValorAtrasado(valor);
    }, delay);

    // Cleanup: se o valor mudar antes do timer disparar,
    // cancela o timer anterior e começa um novo
    // Isso garante que só enviamos o valor "final" após o usuário parar de digitar
    return () => clearTimeout(timer);
  }, [valor, delay]);

  return valorAtrasado;
}
// src/hooks/useProdutos.js
// Hook customizado que encapsula toda a lógica de produtos
// Os componentes não precisam saber como a API funciona — só usam este hook
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
  listarProdutos,
  buscarProduto,
  criarProduto,
  atualizarProduto,
  removerProduto,
} from '../services/produtoService.js';

// Hook para listagem com filtros
export function useProdutos(filtros = {}) {
  return useQuery({
    // A queryKey inclui os filtros — quando filtros mudam, React Query
    // considera como uma query diferente e busca os dados novamente
    queryKey: ['produtos', filtros],
    queryFn: () => listarProdutos(filtros),

    // placeholderData: mantém os dados da query anterior enquanto os novos carregam
    // Isso evita que a UI "pisque" ao mudar de página ou filtro
    placeholderData: (anterior) => anterior,
  });
}

// Hook para um produto específico
export function useProduto(id) {
  return useQuery({
    queryKey: ['produtos', id],
    queryFn: () => buscarProduto(id),
    // enabled: false impede a query de rodar se não houver id
    // Evita chamadas desnecessárias com id undefined
    enabled: !!id,
  });
}

// Hook que agrupa todas as mutações de produto
export function useProdutoMutacoes() {
  const queryClient = useQueryClient();

  // Função auxiliar — invalida o cache de produtos após qualquer mutação
  // Isso faz o React Query refetch a lista automaticamente
  function invalidarProdutos() {
    queryClient.invalidateQueries({ queryKey: ['produtos'] });
  }

  const criar = useMutation({
    mutationFn: criarProduto,
    onSuccess: invalidarProdutos,
  });

  const atualizar = useMutation({
    // mutationFn recebe { id, dados } — desestruturamos aqui
    mutationFn: ({ id, dados }) => atualizarProduto(id, dados),
    onSuccess: invalidarProdutos,
  });

  const remover = useMutation({
    mutationFn: removerProduto,
    onSuccess: invalidarProdutos,
  });

  return { criar, atualizar, remover };
}
// src/hooks/useTarefas.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
  listarTarefas,
  criarTarefa,
  atualizarTarefa,
  removerTarefa,
} from '../services/tarefaService.js';

export function useTarefas(filtros = {}) {
  return useQuery({
    queryKey: ['tarefas', filtros],
    queryFn: () => listarTarefas(filtros),
    placeholderData: (anterior) => anterior,
  });
}

export function useTarefaMutacoes() {
  const queryClient = useQueryClient();

  function invalidarTarefas() {
    queryClient.invalidateQueries({ queryKey: ['tarefas'] });
  }

  const criar = useMutation({
    mutationFn: criarTarefa,
    onSuccess: invalidarTarefas,
  });

  const atualizar = useMutation({
    mutationFn: ({ id, dados }) => atualizarTarefa(id, dados),

    // Update otimista: atualiza a UI ANTES da resposta do servidor
    // Se der erro, revertemos. Isso torna a UI mais responsiva.
    onMutate: async ({ id, dados }) => {
      // Cancela qualquer refetch em andamento para evitar conflito
      await queryClient.cancelQueries({ queryKey: ['tarefas'] });

      // Salva o estado atual do cache para poder reverter se der erro
      const estadoAnterior = queryClient.getQueryData(['tarefas', {}]);

      // Aplica a mudança no cache imediatamente (sem esperar o servidor)
      queryClient.setQueriesData({ queryKey: ['tarefas'] }, (cache) => {
        if (!cache?.dados) return cache;
        return {
          ...cache,
          dados: cache.dados.map((t) =>
            t._id === id ? { ...t, ...dados } : t
          ),
        };
      });

      // Retorna o estado anterior no contexto — disponível em onError
      return { estadoAnterior };
    },

    // Se o servidor retornar erro, revertemos o cache para o estado anterior
    onError: (_erro, _vars, contexto) => {
      if (contexto?.estadoAnterior) {
        queryClient.setQueryData(['tarefas', {}], contexto.estadoAnterior);
      }
    },

    // Independente de sucesso ou erro, revalida o cache ao final
    onSettled: invalidarTarefas,
  });

  const remover = useMutation({
    mutationFn: removerTarefa,
    onSuccess: invalidarTarefas,
  });

  return { criar, atualizar, remover };
}

Páginas

Agora que temos a infraestrutura pronta, as páginas ficam surpreendentemente simples — elas apenas orquestram componentes e hooks.

// src/pages/Login.jsx
// A página de login é intencionalmente simples
// Toda a lógica de autenticação está no authStore
import { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import useAuthStore from '../stores/authStore.js';

export default function Login() {
  const navigate = useNavigate();
  const location = useLocation();

  // Recupera para onde o usuário tentava ir antes de ser redirecionado ao login
  // Se não houver, usa '/dashboard' como destino padrão
  const destino = location.state?.de || '/dashboard';

  const { login, carregando, erro, limparErro } = useAuthStore();

  const [form, setForm] = useState({ email: '', senha: '' });

  function handleChange(e) {
    const { name, value } = e.target;
    // Limpa o erro quando o usuário começa a digitar novamente
    if (erro) limparErro();
    setForm((prev) => ({ ...prev, [name]: value }));
  }

  async function handleSubmit(e) {
    e.preventDefault();
    const resultado = await login(form.email, form.senha);

    if (resultado.sucesso) {
      // replace: true evita que o usuário volte para /login com o botão voltar
      navigate(destino, { replace: true });
    }
    // Se falhou, o erro já está no store e será exibido pelo {erro && ...} abaixo
  }

  return (
    <div className="pagina-auth">
      <div className="card-auth">
        <h1>Entrar</h1>

        {/* Aviso contextual quando o usuário foi redirecionado */}
        {destino !== '/dashboard' && (
          <p className="aviso-redirecionamento">
            Faça login para acessar <strong>{destino}</strong>
          </p>
        )}

        <form onSubmit={handleSubmit} noValidate>
          <div className="campo">
            <label htmlFor="email">Email</label>
            <input
              id="email"
              name="email"
              type="email"
              value={form.email}
              onChange={handleChange}
              placeholder="seu@email.com"
              autoComplete="email"
              required
            />
          </div>

          <div className="campo">
            <label htmlFor="senha">Senha</label>
            <input
              id="senha"
              name="senha"
              type="password"
              value={form.senha}
              onChange={handleChange}
              placeholder="••••••••"
              autoComplete="current-password"
              required
            />
          </div>

          {/* Mensagem de erro vinda do authStore */}
          {erro && <p className="erro-form" role="alert">{erro}</p>}

          <button
            type="submit"
            className="btn-primario btn-bloco"
            disabled={carregando || !form.email || !form.senha}
          >
            {carregando ? 'Entrando...' : 'Entrar'}
          </button>
        </form>

        <p className="link-secundario">
          Não tem conta? <Link to="/cadastro">Cadastre-se</Link>
        </p>
      </div>
    </div>
  );
}
// src/pages/Dashboard.jsx
// O dashboard exibe um resumo geral — dados de múltiplas fontes em paralelo
import { useQuery } from '@tanstack/react-query';
import { listarProdutos } from '../services/produtoService.js';
import { listarTarefas } from '../services/tarefaService.js';
import useAuthStore from '../stores/authStore.js';

export default function Dashboard() {
  const usuario = useAuthStore((state) => state.usuario);

  // Busca produtos e tarefas em paralelo — React Query otimiza automaticamente
  const { data: dadosProdutos, isLoading: carregandoProdutos } = useQuery({
    queryKey: ['produtos', { por_pagina: 5 }],
    queryFn: () => listarProdutos({ por_pagina: 5 }),
  });

  const { data: dadosTarefas, isLoading: carregandoTarefas } = useQuery({
    queryKey: ['tarefas', { por_pagina: 5, status: 'pendente' }],
    queryFn: () => listarTarefas({ por_pagina: 5, status: 'pendente' }),
  });

  // Calcula estatísticas derivadas dos dados já em cache
  const totalProdutos = dadosProdutos?.paginacao?.total ?? 0;
  const totalTarefasPendentes = dadosTarefas?.paginacao?.total ?? 0;

  return (
    <div className="pagina-dashboard">
      <h1>Olá, {usuario?.nome?.split(' ')[0]}! 👋</h1>
      <p className="subtitulo">
        {new Date().toLocaleDateString('pt-BR', {
          weekday: 'long', day: 'numeric', month: 'long',
        })}
      </p>

      {/* Cards de resumo */}
      <div className="grid-cards">
        <div className="card-stat">
          <span className="card-stat__icone">📦</span>
          <div>
            <p className="card-stat__valor">
              {carregandoProdutos ? '...' : totalProdutos}
            </p>
            <p className="card-stat__rotulo">Produtos cadastrados</p>
          </div>
        </div>

        <div className="card-stat">
          <span className="card-stat__icone">✅</span>
          <div>
            <p className="card-stat__valor">
              {carregandoTarefas ? '...' : totalTarefasPendentes}
            </p>
            <p className="card-stat__rotulo">Tarefas pendentes</p>
          </div>
        </div>
      </div>

      {/* Últimas tarefas pendentes */}
      <section className="secao-recentes">
        <h2>Tarefas pendentes</h2>
        {carregandoTarefas ? (
          <p>Carregando...</p>
        ) : (
          <ul className="lista-recentes">
            {dadosTarefas?.dados?.map((t) => (
              <li key={t._id} className={`item-tarefa prioridade-${t.prioridade}`}>
                <span>{t.titulo}</span>
                <span className="badge-prioridade">{t.prioridade}</span>
              </li>
            ))}
            {dadosTarefas?.dados?.length === 0 && (
              <li className="lista-vazia">🎉 Sem tarefas pendentes!</li>
            )}
          </ul>
        )}
      </section>
    </div>
  );
}
// src/pages/Produtos.jsx
// Página de listagem com filtros na URL — o estado dos filtros vive na URL,
// não no componente. Isso permite compartilhar links com filtros aplicados
// e manter os filtros ao voltar da página de detalhe.
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useDebounce } from '../hooks/useDebounce.js';
import { useProdutos, useProdutoMutacoes } from '../hooks/useProdutos.js';

export default function Produtos() {
  // useSearchParams sincroniza os filtros com a URL
  // Ex: /produtos?categoria=eletronicos&pagina=2
  const [searchParams, setSearchParams] = useSearchParams();

  // Lê os filtros da URL — se não existir, usa o valor padrão
  const categoria = searchParams.get('categoria') || '';
  const pagina = Number(searchParams.get('pagina')) || 1;
  const buscaURL = searchParams.get('busca') || '';

  // Estado local apenas para o input de busca (para debounce)
  // O valor "real" que vai para a URL e para a API é o debouncado
  const [buscaInput, setBuscaInput] = useState(buscaURL);
  const buscaDebouncada = useDebounce(buscaInput, 500);

  // Nosso hook customizado cuida de todo o React Query
  const { data, isLoading, isError, error, isFetching } = useProdutos({
    categoria,
    pagina,
    busca: buscaDebouncada,
    por_pagina: 10,
  });

  const { remover } = useProdutoMutacoes();

  // Atualiza um parâmetro na URL sem perder os outros
  function atualizarFiltro(chave, valor) {
    setSearchParams((prev) => {
      if (valor) {
        prev.set(chave, valor);
      } else {
        prev.delete(chave); // remove o parâmetro se o valor for vazio
      }
      // Ao mudar filtros, volta para a página 1
      if (chave !== 'pagina') prev.set('pagina', '1');
      return prev;
    });
  }

  function handleBusca(e) {
    setBuscaInput(e.target.value);
    // Atualiza a URL com a busca debouncada — o useEffect interno do useDebounce
    // vai chamar atualizarFiltro quando o valor estabilizar
    atualizarFiltro('busca', e.target.value);
  }

  function handleRemover(id, nome) {
    if (!confirm(`Remover "${nome}"?`)) return;
    remover.mutate(id);
  }

  return (
    <div className="pagina-produtos">
      <div className="pagina-header">
        <h1>Produtos</h1>
        <Link to="/produtos/novo" className="btn-primario">+ Novo Produto</Link>
      </div>

      {/* Barra de filtros */}
      <div className="barra-filtros">
        <input
          type="search"
          placeholder="Buscar produtos..."
          value={buscaInput}
          onChange={handleBusca}
          className="input-busca"
        />

        <select
          value={categoria}
          onChange={(e) => atualizarFiltro('categoria', e.target.value)}
          className="select-filtro"
        >
          <option value="">Todas as categorias</option>
          <option value="eletronicos">Eletrônicos</option>
          <option value="roupas">Roupas</option>
          <option value="alimentos">Alimentos</option>
          <option value="livros">Livros</option>
          <option value="outros">Outros</option>
        </select>
      </div>

      {/* Indicador de revalidação em segundo plano */}
      {isFetching && !isLoading && (
        <p className="indicador-atualizando">Atualizando...</p>
      )}

      {/* Estados de loading e erro */}
      {isLoading && <div className="skeleton-lista" />}
      {isError && (
        <div className="erro-bloco" role="alert">
          <p>Erro ao carregar produtos: {error.message}</p>
        </div>
      )}

      {/* Lista de produtos */}
      {!isLoading && !isError && (
        <>
          <table className="tabela-produtos">
            <thead>
              <tr>
                <th>Nome</th>
                <th>Categoria</th>
                <th>Preço</th>
                <th>Estoque</th>
                <th>Ações</th>
              </tr>
            </thead>
            <tbody>
              {data?.dados?.map((produto) => (
                <tr key={produto._id}>
                  <td>
                    <Link to={`/produtos/${produto._id}`}>{produto.nome}</Link>
                  </td>
                  <td>
                    <span className="badge-categoria">{produto.categoria}</span>
                  </td>
                  <td>
                    {produto.preco.toLocaleString('pt-BR', {
                      style: 'currency',
                      currency: 'BRL',
                    })}
                  </td>
                  <td>{produto.estoque}</td>
                  <td className="acoes">
                    <Link to={`/produtos/${produto._id}`} className="btn-acao">
                      Ver
                    </Link>
                    <button
                      onClick={() => handleRemover(produto._id, produto.nome)}
                      className="btn-acao btn-acao--perigo"
                      disabled={remover.isPending}
                    >
                      Remover
                    </button>
                  </td>
                </tr>
              ))}

              {data?.dados?.length === 0 && (
                <tr>
                  <td colSpan={5} className="lista-vazia">
                    Nenhum produto encontrado.
                  </td>
                </tr>
              )}
            </tbody>
          </table>

          {/* Paginação */}
          {data?.paginacao && data.paginacao.total_paginas > 1 && (
            <div className="paginacao">
              <button
                onClick={() => atualizarFiltro('pagina', pagina - 1)}
                disabled={pagina <= 1}
                className="btn-pagina"
              >
                ← Anterior
              </button>

              <span className="pagina-info">
                Página {pagina} de {data.paginacao.total_paginas}
                {' '}({data.paginacao.total} resultados)
              </span>

              <button
                onClick={() => atualizarFiltro('pagina', pagina + 1)}
                disabled={pagina >= data.paginacao.total_paginas}
                className="btn-pagina"
              >
                Próxima →
              </button>
            </div>
          )}
        </>
      )}
    </div>
  );
}
// src/pages/Tarefas.jsx
// A página de tarefas demonstra o update otimista em ação
// Ao marcar uma tarefa como concluída, a UI atualiza imediatamente
// sem esperar a confirmação do servidor
import { useState } from 'react';
import { useTarefas, useTarefaMutacoes } from '../hooks/useTarefas.js';

export default function Tarefas() {
  const [filtroStatus, setFiltroStatus] = useState('');
  const [novoTitulo, setNovoTitulo] = useState('');
  const [mostrarForm, setMostrarForm] = useState(false);

  const { data, isLoading } = useTarefas({ status: filtroStatus });
  const { criar, atualizar, remover } = useTarefaMutacoes();

  // Ao clicar no checkbox, atualiza otimisticamente
  // O hook useTarefaMutacoes já tem a lógica de rollback em caso de erro
  function handleAlternarStatus(tarefa) {
    const novoStatus =
      tarefa.status === 'concluida' ? 'pendente' : 'concluida';
    atualizar.mutate({ id: tarefa._id, dados: { status: novoStatus } });
  }

  async function handleCriar(e) {
    e.preventDefault();
    if (!novoTitulo.trim()) return;

    criar.mutate(
      { titulo: novoTitulo.trim(), prioridade: 'media' },
      {
        // onSuccess local — reseta o formulário apenas quando der certo
        onSuccess: () => {
          setNovoTitulo('');
          setMostrarForm(false);
        },
      }
    );
  }

  const iconesPrioridade = { baixa: '🟢', media: '🟡', alta: '🔴' };

  return (
    <div className="pagina-tarefas">
      <div className="pagina-header">
        <h1>Tarefas</h1>
        <button
          onClick={() => setMostrarForm((v) => !v)}
          className="btn-primario"
        >
          {mostrarForm ? 'Cancelar' : '+ Nova Tarefa'}
        </button>
      </div>

      {/* Formulário de nova tarefa — mostrado/escondido com estado local */}
      {mostrarForm && (
        <form onSubmit={handleCriar} className="form-nova-tarefa">
          <input
            type="text"
            value={novoTitulo}
            onChange={(e) => setNovoTitulo(e.target.value)}
            placeholder="Título da tarefa..."
            autoFocus
            required
          />
          <button type="submit" disabled={criar.isPending}>
            {criar.isPending ? 'Criando...' : 'Criar'}
          </button>
        </form>
      )}

      {/* Filtros de status */}
      <div className="filtros-status">
        {['', 'pendente', 'em_progresso', 'concluida'].map((s) => (
          <button
            key={s}
            onClick={() => setFiltroStatus(s)}
            className={`btn-filtro ${filtroStatus === s ? 'ativo' : ''}`}
          >
            {s === '' ? 'Todas' : s.replace('_', ' ')}
          </button>
        ))}
      </div>

      {/* Lista de tarefas */}
      {isLoading ? (
        <p>Carregando tarefas...</p>
      ) : (
        <ul className="lista-tarefas">
          {data?.dados?.map((tarefa) => (
            <li
              key={tarefa._id}
              className={`item-tarefa ${tarefa.status === 'concluida' ? 'concluida' : ''}`}
            >
              <input
                type="checkbox"
                checked={tarefa.status === 'concluida'}
                onChange={() => handleAlternarStatus(tarefa)}
                // Desabilita enquanto outra atualização está pendente
                // para evitar múltiplos cliques simultâneos
                disabled={atualizar.isPending}
                aria-label={`Marcar "${tarefa.titulo}" como concluída`}
              />

              <span className="tarefa-titulo">{tarefa.titulo}</span>

              <span
                className="tarefa-prioridade"
                title={`Prioridade: ${tarefa.prioridade}`}
              >
                {iconesPrioridade[tarefa.prioridade]}
              </span>

              <button
                onClick={() => remover.mutate(tarefa._id)}
                className="btn-remover"
                aria-label="Remover tarefa"
                disabled={remover.isPending}
              >
                🗑
              </button>
            </li>
          ))}

          {data?.dados?.length === 0 && (
            <li className="lista-vazia">
              {filtroStatus
                ? `Nenhuma tarefa com status "${filtroStatus}".`
                : '🎉 Sem tarefas! Adicione uma acima.'}
            </li>
          )}
        </ul>
      )}
    </div>
  );
}

O que este projeto exercitou

A tabela abaixo mostra como cada conceito do Módulo 6 apareceu na aplicação. Nenhum foi decorativo — cada um resolveu um problema real.

| Conceito | Onde foi aplicado | Problema resolvido |

|---|---|---|

| Componentes e JSX | Todas as páginas e componentes | Reutilização e composição de UI |

| useState | Formulários, filtros locais | Estado local transitório |

| useEffect | Hooks customizados | Sincronização com APIs |

| useContext | Via Zustand internamente | Estado global sem prop drilling |

| useReducer | Zustand sob o capô | Transições de estado previsíveis |

| useMemo | Cálculo de filtros de URL | Evitar recálculos desnecessários |

| useCallback | Handlers de eventos memorizados | Estabilizar referências de função |

| useRef | Formulários e timers | Valores sem re-render |

| Hook customizado | useProdutos, useTarefas, useDebounce | Lógica reutilizável entre páginas |

| Zustand | authStore | Estado de UI global com persist |

| React Query | Todas as chamadas de API | Cache, loading, erro automáticos |

| Update otimista | Alternar status da tarefa | UI responsiva sem esperar servidor |

| React Router | App.jsx, todas as páginas | Navegação sem reload |

| Rotas protegidas | RotaProtegida, RotaPublica | Autorização por rota |

| useSearchParams | Página de Produtos | Filtros persistidos na URL |

| Lazy loading | Todas as páginas | Bundle inicial menor |

| Suspense | App.jsx | Fallback durante carregamento |


Você construiu uma SPA completa e profissional. Não um tutorial simplificado — uma aplicação com arquitetura real: separação de camadas, estado bem organizado, navegação robusta, UX responsiva com updates otimistas, e código que escala.


 

📚 Fontes e Referências

  • React — Documentação oficial: https://react.dev
  • React Router — Documentação: https://reactrouter.com/en/main
  • Zustand — Documentação: https://zustand.docs.pmnd.rs
  • TanStack Query — Documentação: https://tanstack.com/query/latest
  • Vite — Documentação: https://vitejs.dev
  • Fluent React — Tejas Kumar (O'Reilly)
  • Full Stack React — Anthony Accomazzo (Fullstack.io)
  • Road to React — Robin Wieruch: https://www.roadtoreact.com
  • roadmap.sh — React: https://roadmap.sh/react
Comentários

Mais em Javascript

Projeto Final: Revisão Completa e Aplicação de Produção
Projeto Final: Revisão Completa e Aplicação de Produção

Artigo 52 — Projeto Final: Revisão Completa e Aplicação de ProduçãoPor Prof....

MongoDB e Mongoose: banco de dados com Node
MongoDB e Mongoose: banco de dados com Node

Até agora nossa API guardava dados em um array na memória — tudo sumia quando...

O que é o DOM e como o JavaScript interage com o HTML
O que é o DOM e como o JavaScript interage com o HTML

At&eacute; agora todo o nosso c&oacute;digo rodou no console &mdash; um ambie...