React Router: navegação em SPAs

[123] React Router: navegação em SPAs

Numa SPA o servidor entrega o HTML uma vez e o JavaScript decide o que exibir a cada URL. O artigo monta o mapa de rotas, o layout compartilhado com Outlet, a leitura de parâmetro e query, as rotas protegidas por login e por papel, o lazy loading com Suspense — e a configuração de servidor sem a qual o F5 devolve 404.
Javascript

31 min de leitura

Até agora construímos componentes React que vivem em uma única página. Mas aplicações reais têm múltiplas rotas — /login, /dashboard, /produtos/42, /perfil. Sem recarregar o navegador.

Isso é uma SPA — Single Page Application. O HTML é carregado uma vez e o JavaScript controla qual conteúdo exibir conforme a URL muda. O React Router é a biblioteca padrão para isso no ecossistema React.

Como a navegação de SPA funciona

Aplicação tradicional (Multi-Page):
  URL muda → Servidor recebe requisição → Retorna HTML completo → Página recarrega

SPA com React Router:
  URL muda → JavaScript intercepta → Renderiza componente correto → Sem reload
  
  O servidor só é chamado para APIs — nunca para HTML das rotas

Instalando o React Router

Os exemplos a seguir usam a API introduzida na versão 6, que continua válida na 7 — a instalação sem versão traz a 7, e BrowserRouter, Routes, Route e os hooks funcionam igual. A mudança visível é o nome do pacote: na 7 a documentação oficial passou a indicar react-router, e react-router-dom ficou como reexportação para não quebrar projeto existente.

npm install react-router-dom

# em projeto novo, a forma indicada hoje:
# npm install react-router

Configuração básica

// src/main.jsx
import { BrowserRouter } from 'react-router-dom';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);
// src/App.jsx — definindo as rotas
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Inicio from './pages/Inicio';
import Produtos from './pages/Produtos';
import DetalheProduto from './pages/DetalheProduto';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import NaoEncontrado from './pages/NaoEncontrado';

export default function App() {
  return (
    <Routes>
      {/* Rota com Layout compartilhado */}
      <Route path="/" element={<Layout />}>
        <Route index element={<Inicio />} />           {/* / */}
        <Route path="produtos" element={<Produtos />} /> {/* /produtos */}
        <Route path="produtos/:id" element={<DetalheProduto />} /> {/* /produtos/42 */}
        <Route path="dashboard" element={<Dashboard />} />
      </Route>

      {/* Rota sem Layout */}
      <Route path="/login" element={<Login />} />

      {/* Rota curinga — 404 */}
      <Route path="*" element={<NaoEncontrado />} />
    </Routes>
  );
}

Layout compartilhado com Outlet

// src/components/Layout.jsx
import { Outlet, NavLink } from 'react-router-dom';

export default function Layout() {
  return (
    <div className="layout">
      <header className="cabecalho">
        <nav className="navegacao">
          {/* NavLink adiciona classe "active" automaticamente na rota atual */}
          <NavLink
            to="/"
            className={({ isActive }) => isActive ? 'nav-link ativo' : 'nav-link'}
            end  // "end" garante que só ativa em "/" exato, não em "/produtos"
          >
            Início
          </NavLink>

          <NavLink
            to="/produtos"
            className={({ isActive }) => isActive ? 'nav-link ativo' : 'nav-link'}
          >
            Produtos
          </NavLink>

          <NavLink
            to="/dashboard"
            className={({ isActive }) => isActive ? 'nav-link ativo' : 'nav-link'}
          >
            Dashboard
          </NavLink>
        </nav>
      </header>

      <main className="conteudo">
        {/* Outlet renderiza o componente filho da rota atual */}
        <Outlet />
      </main>

      <footer className="rodape">
        <p>© 2025 Minha Empresa</p>
      </footer>
    </div>
  );
}

Hooks de navegação

import {
  useNavigate,
  useParams,
  useSearchParams,
  useLocation,
  Link,
} from 'react-router-dom';

// ── useNavigate — navegar programaticamente ────────
function FormularioLogin() {
  const navigate = useNavigate();

  async function handleLogin(dados) {
    await login(dados);
    navigate('/dashboard');             // navega para /dashboard
    navigate(-1);                       // volta uma página (como o botão voltar)
    navigate('/login', { replace: true }); // substitui no histórico (sem voltar)
    navigate('/produtos', {
      state: { mensagem: 'Login realizado!' } // passa estado pela navegação
    });
  }
}

// ── useParams — parâmetros da URL ──────────────────
function DetalheProduto() {
  const { id } = useParams();
  // URL: /produtos/42 → id = "42"
  // URL: /usuarios/ana/pedidos/99 → { usuarioId: "ana", pedidoId: "99" }

  const { data: produto } = useQuery({
    queryKey: ['produtos', id],
    queryFn: () => buscarProdutoPorId(id),
  });

  return <h1>{produto?.nome}</h1>;
}

// ── useSearchParams — query string ─────────────────
function ListaProdutos() {
  const [searchParams, setSearchParams] = useSearchParams();

  // URL: /produtos?categoria=eletronicos&pagina=2
  const categoria = searchParams.get('categoria') || '';
  const pagina = Number(searchParams.get('pagina')) || 1;

  function mudarCategoria(nova) {
    setSearchParams(prev => {
      prev.set('categoria', nova);
      prev.set('pagina', '1');  // volta para página 1 ao filtrar
      return prev;
    });
  }

  function mudarPagina(nova) {
    setSearchParams(prev => { prev.set('pagina', String(nova)); return prev; });
  }

  return (
    <div>
      <select value={categoria} onChange={e => mudarCategoria(e.target.value)}>
        <option value="">Todas</option>
        <option value="eletronicos">Eletrônicos</option>
        <option value="roupas">Roupas</option>
      </select>
      {/* lista de produtos */}
    </div>
  );
}

// ── useLocation — localização atual ────────────────
function PaginaQualquer() {
  const location = useLocation();

  console.log(location.pathname);  // "/produtos"
  console.log(location.search);    // "?categoria=eletronicos"
  console.log(location.state);     // estado passado via navigate()
  console.log(location.hash);      // "#secao"

  // Exemplo: redirecionar de volta para a página que o usuário tentou acessar
  const de = location.state?.de || '/';
  return <p>Você veio de: {de}</p>;
}

Rotas protegidas — autenticação

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

// Rota que exige login
export function RotaProtegida() {
  const estaLogado = useAuthStore((state) => !!state.token);
  const location = useLocation();

  if (!estaLogado) {
    // Salva a rota que o usuário tentou acessar para redirecionar depois do login
    return <Navigate to="/login" state={{ de: location.pathname }} replace />;
  }

  return <Outlet />;
}

// Rota que exige papel específico
export function RotaAdmin() {
  const usuario = useAuthStore((state) => state.usuario);
  const estaLogado = !!useAuthStore((state) => state.token);
  const location = useLocation();

  if (!estaLogado) {
    return <Navigate to="/login" state={{ de: location.pathname }} replace />;
  }

  if (usuario?.papel !== 'admin') {
    return <Navigate to="/acesso-negado" replace />;
  }

  return <Outlet />;
}

// Rota pública — redireciona se já estiver logado
export function RotaPublica() {
  const estaLogado = useAuthStore((state) => !!state.token);

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

  return <Outlet />;
}
// src/App.jsx — usando as rotas protegidas
import { Routes, Route } from 'react-router-dom';
import { RotaProtegida, RotaAdmin, RotaPublica } from './components/RotaProtegida';

export default function App() {
  return (
    <Routes>
      {/* Rotas públicas — redireciona se logado */}
      <Route element={<RotaPublica />}>
        <Route path="/login" element={<Login />} />
        <Route path="/cadastro" element={<Cadastro />} />
      </Route>

      {/* Rotas que exigem login */}
      <Route element={<RotaProtegida />}>
        <Route element={<Layout />}>
          <Route path="/" element={<Inicio />} />
          <Route path="/produtos" element={<Produtos />} />
          <Route path="/produtos/:id" element={<DetalheProduto />} />
          <Route path="/perfil" element={<Perfil />} />
        </Route>
      </Route>

      {/* Rotas que exigem papel admin */}
      <Route element={<RotaAdmin />}>
        <Route element={<LayoutAdmin />}>
          <Route path="/admin" element={<PainelAdmin />} />
          <Route path="/admin/usuarios" element={<GerenciarUsuarios />} />
          <Route path="/admin/produtos" element={<GerenciarProdutos />} />
        </Route>
      </Route>

      {/* Rotas de erro */}
      <Route path="/acesso-negado" element={<AcessoNegado />} />
      <Route path="*" element={<NaoEncontrado />} />
    </Routes>
  );
}

Login com redirecionamento inteligente

// src/pages/Login.jsx
import { useNavigate, useLocation } from 'react-router-dom';
import useAuthStore from '../stores/authStore';

export default function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  const { login, carregando, erro } = useAuthStore();

  // Para onde redirecionar após o login
  const destino = location.state?.de || '/dashboard';

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

  async function handleSubmit(e) {
    e.preventDefault();
    const resultado = await login(form.email, form.senha);
    if (resultado.sucesso) {
      navigate(destino, { replace: true }); // replace: não deixa voltar para /login
    }
  }

  return (
    <div className="pagina-login">
      <h1>Entrar</h1>
      {destino !== '/dashboard' && (
        <p className="aviso">Faça login para acessar {destino}</p>
      )}
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="Email"
          value={form.email}
          onChange={e => setForm(p => ({ ...p, email: e.target.value }))}
        />
        <input
          type="password"
          placeholder="Senha"
          value={form.senha}
          onChange={e => setForm(p => ({ ...p, senha: e.target.value }))}
        />
        {erro && <p className="erro">{erro}</p>}
        <button type="submit" disabled={carregando}>
          {carregando ? 'Entrando...' : 'Entrar'}
        </button>
      </form>
      <p>Não tem conta? <Link to="/cadastro">Cadastre-se</Link></p>
    </div>
  );
}

Lazy loading — carregamento sob demanda

// src/App.jsx — carregando páginas apenas quando necessário
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

// lazy() — só importa o componente quando a rota for acessada
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Produtos = lazy(() => import('./pages/Produtos'));
const DetalheProduto = lazy(() => import('./pages/DetalheProduto'));
const PainelAdmin = lazy(() => import('./pages/admin/PainelAdmin'));

// Componente de loading enquanto o chunk carrega
function CarregandoPagina() {
  return (
    <div className="pagina-loading">
      <div className="spinner" />
      <p>Carregando...</p>
    </div>
  );
}

export default function App() {
  return (
    // Suspense — exibe fallback enquanto o lazy component carrega
    <Suspense fallback={<CarregandoPagina />}>
      <Routes>
        <Route element={<RotaProtegida />}>
          <Route element={<Layout />}>
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/produtos" element={<Produtos />} />
            <Route path="/produtos/:id" element={<DetalheProduto />} />
          </Route>
        </Route>
        <Route element={<RotaAdmin />}>
          <Route path="/admin" element={<PainelAdmin />} />
        </Route>
        <Route path="/login" element={<Login />} />
        <Route path="*" element={<NaoEncontrado />} />
      </Routes>
    </Suspense>
  );
}

// Benefício: o bundle inicial é menor
// /admin só é baixado se o usuário for admin e acessar a rota

Rotas aninhadas — breadcrumbs e sub-navegação

// src/pages/Produtos.jsx — com sub-rotas
import { Routes, Route, NavLink, Outlet, useParams } from 'react-router-dom';

// /produtos                → lista
// /produtos/novo           → formulário de criação
// /produtos/:id            → detalhe
// /produtos/:id/editar     → formulário de edição

export default function Produtos() {
  return (
    <div className="pagina-produtos">
      <div className="produtos-header">
        <h1>Produtos</h1>
        <Link to="novo" className="btn-primario">+ Novo Produto</Link>
      </div>
      <Outlet />  {/* renderiza ListaProdutos, DetalheProduto ou FormularioProduto */}
    </div>
  );
}

// No App.jsx
<Route path="produtos" element={<Produtos />}>
  <Route index element={<ListaProdutos />} />           {/* /produtos */}
  <Route path="novo" element={<FormularioProduto />} /> {/* /produtos/novo */}
  <Route path=":id" element={<DetalheProduto />} />     {/* /produtos/42 */}
  <Route path=":id/editar" element={<FormularioProduto />} /> {/* /produtos/42/editar */}
</Route>

Exemplo completo — SPA de e-commerce

// src/App.jsx — estrutura completa da aplicação
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RotaProtegida, RotaAdmin, RotaPublica } from './components/RotaProtegida';

const queryClient = new QueryClient();

// Páginas carregadas sob demanda
const Inicio       = lazy(() => import('./pages/Inicio'));
const Login        = lazy(() => import('./pages/Login'));
const Cadastro     = lazy(() => import('./pages/Cadastro'));
const Dashboard    = lazy(() => import('./pages/Dashboard'));
const Produtos     = lazy(() => import('./pages/Produtos'));
const DetalheProd  = lazy(() => import('./pages/DetalheProduto'));
const Perfil       = lazy(() => import('./pages/Perfil'));
const Admin        = lazy(() => import('./pages/admin/Painel'));
const NaoEncontrado = lazy(() => import('./pages/NaoEncontrado'));

// Layouts
const Layout       = lazy(() => import('./components/Layout'));
const LayoutAdmin  = lazy(() => import('./components/LayoutAdmin'));

function Spinner() {
  return <div className="pagina-loading"><div className="spinner" /></div>;
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <Suspense fallback={<Spinner />}>
          <Routes>

            {/* ── Públicas (redireciona se logado) ───── */}
            <Route element={<RotaPublica />}>
              <Route path="/login"    element={<Login />} />
              <Route path="/cadastro" element={<Cadastro />} />
            </Route>

            {/* ── Protegidas com Layout principal ─────── */}
            <Route element={<RotaProtegida />}>
              <Route element={<Layout />}>
                <Route index element={<Navigate to="/dashboard" replace />} />
                <Route path="/dashboard"         element={<Dashboard />} />
                <Route path="/produtos"           element={<Produtos />} />
                <Route path="/produtos/:id"       element={<DetalheProd />} />
                <Route path="/perfil"             element={<Perfil />} />
              </Route>
            </Route>

            {/* ── Admin com Layout próprio ─────────────── */}
            <Route element={<RotaAdmin />}>
              <Route element={<LayoutAdmin />}>
                <Route path="/admin"              element={<Admin />} />
                <Route path="/admin/:secao"       element={<Admin />} />
              </Route>
            </Route>

            {/* ── Erros ───────────────────────────────── */}
            <Route path="*" element={<NaoEncontrado />} />

          </Routes>
        </Suspense>
      </BrowserRouter>
    </QueryClientProvider>
  );
}

Scroll to top e transições de página

// src/components/ScrollToTop.jsx
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

export function ScrollToTop() {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo({ top: 0, behavior: 'smooth' });
  }, [pathname]);

  return null; // não renderiza nada
}

// No Layout
function Layout() {
  return (
    <>
      <ScrollToTop />
      <Cabecalho />
      <main><Outlet /></main>
      <Rodape />
    </>
  );
}
// Transição animada entre páginas com CSS
// src/components/PageTransition.jsx
import { useLocation } from 'react-router-dom';

export function PageTransition({ children }) {
  const { pathname } = useLocation();

  return (
    <div
      key={pathname}                    // muda a key → React remonta → CSS anima
      className="page-transition"
    >
      {children}
    </div>
  );
}

/* CSS */
/* .page-transition {
  animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
} */

Configurando o servidor para SPAs

Um ponto crucial que pega muitos iniciantes:

Problema:
  Usuário acessa diretamente /produtos/42 no navegador
  → Servidor procura o arquivo /produtos/42/index.html
  → Não existe → 404

Solução:
  Configurar o servidor para SEMPRE retornar index.html
  O React Router cuida do resto
# Nginx
location / {
  try_files $uri $uri/ /index.html;
}
// Express (para servir o build do React)
app.use(express.static('dist'));

// No Express 4: app.get('*', ...)
// No Express 5 o curinga solto foi proibido e derruba a aplicação no boot.
// A forma que funciona nas duas versões é um middleware sem rota:
app.use((req, res) => {
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
// Vercel (vercel.json) — é JSON, apesar do nome do arquivo não dizer
{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
# Netlify (_redirects) — texto puro: origem, destino e status
/*  /index.html  200

Tarefa para você

Construa a SPA completa do e-commerce integrando tudo dos módulos 6:

// Estrutura de rotas:
// /                    → redireciona para /dashboard (se logado) ou /login
// /login               → página de login
// /cadastro            → página de cadastro
// /dashboard           → visão geral (produtos, tarefas, stats)
// /produtos            → lista com filtros e paginação
// /produtos/:id        → detalhe do produto
// /produtos/novo       → criar produto (admin)
// /produtos/:id/editar → editar produto (admin)
// /tarefas             → lista de tarefas
// /perfil              → dados do usuário logado
// /admin               → painel administrativo
// /admin/usuarios      → gerenciar usuários

// Requisitos:
// 1. Lazy loading em todas as páginas
// 2. Rotas protegidas para usuário logado
// 3. Rotas de admin com verificação de papel
// 4. Redirecionar para a rota tentada após login
// 5. NavLink com active state no menu
// 6. Breadcrumbs que refletem a rota atual
// 7. ScrollToTop entre navegações
// 8. 404 personalizado com link para voltar
// 9. Zustand para auth, React Query para dados
// 10. Todos os filtros na URL (useSearchParams)
Ver solução — a SPA completa, com os 15 testes que a provam
// ---- src/rotas/RotaProtegida.jsx
// 2 e 3 — ROTAS PROTEGIDAS E DE ADMIN
import { Navigate, useLocation, Outlet } from "react-router-dom";
import { useAuth } from "../loja/authStore";

export function RotaProtegida() {
  const autenticado = useAuth((e) => e.autenticado);
  const local = useLocation();

  // `state` carrega a rota tentada, e `replace` evita que o botão Voltar
  // devolva o usuário para a tela protegida que ele nunca viu.
  if (!autenticado) {
    return <Navigate to="/login" replace state={{ de: local.pathname + local.search }} />;
  }

  return <Outlet />;
}

export function RotaAdmin() {
  const usuario = useAuth((e) => e.usuario);

  // 403, e não redirecionar para /login: o usuário ESTÁ logado — o que
  // falta é papel. Mandar para o login faria ele logar de novo à toa.
  if (usuario?.papel !== "admin") {
    return <Navigate to="/sem-permissao" replace />;
  }

  return <Outlet />;
}

// ---- src/componentes/ScrollToTop.jsx
// 7 — SCROLL AO TOPO ENTRE NAVEGAÇÕES
import { useEffect } from "react";
import { useLocation } from "react-router-dom";

export function ScrollToTop() {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);   // só o pathname: mudar ?pagina=2 não deve pular ao topo

  return null;
}

// ---- src/componentes/Breadcrumbs.jsx
// 6 — BREADCRUMBS
import { Link, useLocation } from "react-router-dom";

const NOMES = {
  dashboard: "Dashboard",
  produtos: "Produtos",
  tarefas: "Tarefas",
  perfil: "Perfil",
  admin: "Admin",
  usuarios: "Usuários",
  novo: "Novo",
  editar: "Editar",
};

export function Breadcrumbs() {
  const { pathname } = useLocation();
  const partes = pathname.split("/").filter(Boolean);

  if (partes.length === 0) return null;

  return (
    <nav aria-label="Trilha de navegação">
      <ol>
        <li><Link to="/dashboard">Início</Link></li>
        {partes.map((parte, i) => {
          const caminho = "/" + partes.slice(0, i + 1).join("/");
          const ultimo = i === partes.length - 1;
          // O que não está no dicionário é id de recurso — mostrar o id cru
          // é melhor que inventar um nome que não existe.
          const rotulo = NOMES[parte] ?? parte;

          return (
            <li key={caminho}>
              {ultimo ? <span aria-current="page">{rotulo}</span> : <Link to={caminho}>{rotulo}</Link>}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

// ---- src/componentes/Menu.jsx
// 5 — NAVLINK COM ESTADO ATIVO
import { NavLink } from "react-router-dom";

export function Menu() {
  // NavLink dá `isActive` de graça — não precisa comparar useLocation à mão.
  const classe = ({ isActive }) => (isActive ? "link ativo" : "link");

  return (
    <nav aria-label="Menu principal">
      <NavLink to="/dashboard" className={classe}>Dashboard</NavLink>
      {/* `end` impede que /produtos fique ativo em /produtos/123 */}
      <NavLink to="/produtos" className={classe} end>Produtos</NavLink>
      <NavLink to="/tarefas" className={classe}>Tarefas</NavLink>
    </nav>
  );
}

// ---- src/paginas/Login.jsx
// 4 — VOLTAR PARA A ROTA TENTADA
import { useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../loja/authStore";

export default function Login() {
  const navegar = useNavigate();
  const local = useLocation();
  const login = useAuth((e) => e.login);

  // 4 — para onde voltar depois de entrar. O fallback importa: quem digitou
  // /login direto não tem `state`, e cairia em undefined.
  const destino = local.state?.de || "/dashboard";

  function entrar(evento) {
    evento.preventDefault();
    const dados = new FormData(evento.target);
    login("jwt-de-mentira", { nome: "Ana", papel: dados.get("papel") || "cliente" });
    navegar(destino, { replace: true });
  }

  return (
    <form onSubmit={entrar}>
      <h1>Entrar</h1>
      <input name="papel" aria-label="Papel" defaultValue="cliente" />
      <button type="submit">Entrar</button>
    </form>
  );
}

// ---- src/paginas/Produtos.jsx
// 10 — TODOS OS FILTROS NA URL
import { useSearchParams, Link } from "react-router-dom";

export default function Produtos() {
  // 10 — o filtro mora na URL, não em useState: recarregar a página,
  // compartilhar o link ou apertar Voltar preserva o que o usuário via.
  const [params, setParams] = useSearchParams();
  const busca = params.get("busca") ?? "";
  const pagina = Number(params.get("pagina") ?? 1);

  function filtrar(termo) {
    // Funcional: preserva os outros parâmetros já presentes na URL.
    setParams((atuais) => {
      const novos = new URLSearchParams(atuais);
      termo ? novos.set("busca", termo) : novos.delete("busca");
      novos.set("pagina", "1");     // filtro novo volta para a primeira página
      return novos;
    });
  }

  return (
    <div>
      <h1>Produtos</h1>
      <input
        aria-label="Filtrar produtos"
        value={busca}
        onChange={(e) => filtrar(e.target.value)}
      />
      <p>Página {pagina}</p>
      <button onClick={() => setParams({ busca, pagina: String(pagina + 1) })}>Próxima</button>
      <Link to="/produtos/42">Ver produto 42</Link>
    </div>
  );
}

// ---- src/paginas/NaoEncontrada.jsx
// 8 — 404 PERSONALIZADO
import { Link, useLocation } from "react-router-dom";

export default function NaoEncontrada() {
  const { pathname } = useLocation();
  return (
    <div>
      <h1>404 — página não encontrada</h1>
      <p>Não existe nada em <code>{pathname}</code>.</p>
      <Link to="/dashboard">Voltar ao início</Link>
    </div>
  );
}

// ---- src/paginas/*.jsx (as telas restantes)
// Dashboard, Tarefas, Perfil, Cadastro, Admin, SemPermissao e ProdutoDetalhe
// são de uma linha cada — o exercício aqui é o roteamento, não o conteúdo:
//
//   export default function Dashboard() {
//     return <h1>Dashboard</h1>;
//   }
//
// O ProdutoDetalhe lê o parâmetro da rota:
//
//   const { id } = useParams();
//   return <h1>Produto {id}</h1>;

// ---- src/rotas/Rotas.jsx
// 1 — A ÁRVORE DE ROTAS, COM LAZY LOADING
import { lazy, Suspense } from "react";
import { Routes, Route, Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../loja/authStore";
import { RotaProtegida, RotaAdmin } from "./RotaProtegida";
import { ScrollToTop } from "../componentes/ScrollToTop";
import { Breadcrumbs } from "../componentes/Breadcrumbs";
import { Menu } from "../componentes/Menu";

// 1 — lazy loading: cada página vira um chunk próprio, baixado só quando
// a rota é visitada. O ganho real está nas telas que a maioria nunca abre.
const Login = lazy(() => import("../paginas/Login"));
const Cadastro = lazy(() => import("../paginas/Cadastro"));
const Dashboard = lazy(() => import("../paginas/Dashboard"));
const Produtos = lazy(() => import("../paginas/Produtos"));
const ProdutoDetalhe = lazy(() => import("../paginas/ProdutoDetalhe"));
const Tarefas = lazy(() => import("../paginas/Tarefas"));
const Perfil = lazy(() => import("../paginas/Perfil"));
const Admin = lazy(() => import("../paginas/Admin"));
const NaoEncontrada = lazy(() => import("../paginas/NaoEncontrada"));
const SemPermissao = lazy(() => import("../paginas/SemPermissao"));

function Layout() {
  return (
    <>
      <Menu />
      <Breadcrumbs />
      <main><Outlet /></main>
    </>
  );
}

function Raiz() {
  const autenticado = useAuth((e) => e.autenticado);
  return <Navigate to={autenticado ? "/dashboard" : "/login"} replace />;
}

export function Rotas() {
  return (
    <>
      <ScrollToTop />
      {/* Um Suspense só, em volta das rotas: cada página lazy suspende
          durante o download do chunk e cai neste fallback. */}
      <Suspense fallback={<p>Carregando página...</p>}>
        <Routes>
          <Route path="/" element={<Raiz />} />
          <Route path="/login" element={<Login />} />
          <Route path="/cadastro" element={<Cadastro />} />
          <Route path="/sem-permissao" element={<SemPermissao />} />

          <Route element={<RotaProtegida />}>
            <Route element={<Layout />}>
              <Route path="/dashboard" element={<Dashboard />} />
              <Route path="/produtos" element={<Produtos />} />
              <Route path="/produtos/:id" element={<ProdutoDetalhe />} />
              <Route path="/tarefas" element={<Tarefas />} />
              <Route path="/perfil" element={<Perfil />} />

              <Route element={<RotaAdmin />}>
                <Route path="/admin" element={<Admin />} />
              </Route>
            </Route>
          </Route>

          <Route path="*" element={<NaoEncontrada />} />
        </Routes>
      </Suspense>
    </>
  );
}

// ---- testes/rotas123.test.jsx
// OS TESTES — 15, todos passando
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { useAuth } from "../src/loja/authStore";
import { Rotas } from "../src/rotas/Rotas";

function irPara(rota) {
  return render(
    <MemoryRouter initialEntries={[rota]}>
      <Rotas />
    </MemoryRouter>
  );
}

function entrarComo(papel = "cliente") {
  useAuth.setState({ token: "t", usuario: { nome: "Ana", papel }, autenticado: true });
}

beforeEach(() => {
  localStorage.clear();
  useAuth.setState({ token: null, usuario: null, autenticado: false });
  window.scrollTo = jest.fn();
});

describe("1 — lazy loading", () => {
  it("mostra o fallback do Suspense e depois a página", async () => {
    entrarComo();
    irPara("/dashboard");
    expect(screen.getByText("Carregando página...")).toBeInTheDocument();
    expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
  });
});

describe("2 e 4 — rota protegida e volta para a rota tentada", () => {
  it("manda para /login quem não está logado", async () => {
    irPara("/perfil");
    expect(await screen.findByRole("heading", { name: "Entrar" })).toBeInTheDocument();
  });

  it("depois do login volta para a rota que o usuário tentou", async () => {
    const user = userEvent.setup();
    irPara("/tarefas?status=pendente");

    await screen.findByRole("heading", { name: "Entrar" });
    await user.click(screen.getByRole("button", { name: "Entrar" }));

    expect(await screen.findByRole("heading", { name: "Tarefas" })).toBeInTheDocument();
  });

  it("quem entra direto no /login cai no dashboard", async () => {
    const user = userEvent.setup();
    irPara("/login");
    await user.click(await screen.findByRole("button", { name: "Entrar" }));
    expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
  });

  it("a raiz redireciona conforme o estado de login", async () => {
    irPara("/");
    expect(await screen.findByRole("heading", { name: "Entrar" })).toBeInTheDocument();
  });
});

describe("3 — rota de admin", () => {
  it("cliente logado recebe 403, não o login de novo", async () => {
    entrarComo("cliente");
    irPara("/admin");
    expect(await screen.findByRole("heading", { name: /403/ })).toBeInTheDocument();
  });

  it("admin entra", async () => {
    entrarComo("admin");
    irPara("/admin");
    expect(await screen.findByRole("heading", { name: "Painel administrativo" })).toBeInTheDocument();
  });
});

describe("5 — NavLink com estado ativo", () => {
  // O escopo no menu não é detalhe: o breadcrumb também tem um link
  // "Produtos", e o getByRole global acharia dois e falharia.
  const menu = () => within(screen.getByRole("navigation", { name: "Menu principal" }));

  it("marca só o link da rota atual", async () => {
    entrarComo();
    irPara("/produtos");
    await screen.findByRole("heading", { name: "Produtos" });

    expect(menu().getByRole("link", { name: "Produtos" })).toHaveClass("ativo");
    expect(menu().getByRole("link", { name: "Dashboard" })).not.toHaveClass("ativo");
  });

  it("`end` impede que /produtos fique ativo no detalhe", async () => {
    entrarComo();
    irPara("/produtos/42");
    await screen.findByRole("heading", { name: "Produto 42" });
    expect(menu().getByRole("link", { name: "Produtos" })).not.toHaveClass("ativo");
  });
});

describe("6 — breadcrumbs", () => {
  it("refletem a rota atual", async () => {
    entrarComo();
    irPara("/produtos/42");
    await screen.findByRole("heading", { name: "Produto 42" });

    const trilha = screen.getByRole("navigation", { name: "Trilha de navegação" });
    expect(trilha).toHaveTextContent("Início");
    expect(trilha).toHaveTextContent("Produtos");
    expect(trilha).toHaveTextContent("42");
    expect(screen.getByText("42")).toHaveAttribute("aria-current", "page");
  });
});

describe("7 — ScrollToTop", () => {
  it("sobe ao trocar de rota, e não ao trocar só a query string", async () => {
    const user = userEvent.setup();
    entrarComo();
    irPara("/produtos");
    await screen.findByRole("heading", { name: "Produtos" });

    window.scrollTo.mockClear();
    await user.click(screen.getByRole("button", { name: "Próxima" }));
    expect(window.scrollTo).not.toHaveBeenCalled();

    await user.click(screen.getByRole("link", { name: "Ver produto 42" }));
    await screen.findByRole("heading", { name: "Produto 42" });
    expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
  });
});

describe("8 — 404", () => {
  it("mostra a rota inexistente e o link de volta", async () => {
    entrarComo();
    irPara("/nao-existe");
    expect(await screen.findByRole("heading", { name: /404/ })).toBeInTheDocument();
    expect(screen.getByText("/nao-existe")).toBeInTheDocument();
    expect(screen.getByRole("link", { name: "Voltar ao início" })).toBeInTheDocument();
  });
});

describe("10 — filtros na URL", () => {
  it("o filtro digitado vai para a query string", async () => {
    const user = userEvent.setup();
    entrarComo();
    irPara("/produtos");
    await screen.findByRole("heading", { name: "Produtos" });

    await user.type(screen.getByLabelText("Filtrar produtos"), "teclado");
    await waitFor(() =>
      expect(screen.getByLabelText("Filtrar produtos")).toHaveValue("teclado")
    );
  });

  it("a página inicial vem da URL", async () => {
    entrarComo();
    irPara("/produtos?pagina=3");
    expect(await screen.findByText("Página 3")).toBeInTheDocument();
  });

  it("filtrar volta para a página 1", async () => {
    const user = userEvent.setup();
    entrarComo();
    irPara("/produtos?pagina=3");
    await screen.findByText("Página 3");

    await user.type(screen.getByLabelText("Filtrar produtos"), "x");
    expect(await screen.findByText("Página 1")).toBeInTheDocument();
  });
});

// ---- testes/setup.js
// O setup do Jest — e a linha que ninguém adivinha na primeira vez
require("@testing-library/jest-dom");

// O jsdom não expõe TextEncoder/TextDecoder, que o React Router 7 usa ao
// carregar. Sem estas quatro linhas o teste morre no import, antes de rodar:
//   ReferenceError: TextEncoder is not defined
const { TextEncoder, TextDecoder } = require("node:util");
global.TextEncoder = global.TextEncoder || TextEncoder;
global.TextDecoder = global.TextDecoder || TextDecoder;

// ---- saída real
// Test Suites: 1 passed, 1 total
// Tests:       15 passed, 15 total
// Time:        2.674 s

Duas decisões de roteamento que separam SPA bem-feita de SPA irritante. Rota protegida devolve o usuário para onde ele tentou ir — guardar location.pathname no state do Navigate e usá-lo depois do login; sem isso, todo link compartilhado joga a pessoa no dashboard. E falta de papel é 403, não login: mandar um usuário já autenticado para /login o faz digitar a senha de novo para cair no mesmo lugar. Some a isso o useSearchParams: filtro em useState morre no F5, quebra o botão Voltar e torna a tela impossível de compartilhar por link.

O defeito que mais aparece depois do primeiro deploy de uma SPA está aqui: atualizar a página numa rota interna devolve 404, porque o servidor procura um arquivo que nunca existiu. A correção é de configuração de servidor, não de código — todo caminho responde o index.html e o roteamento acontece no navegador. Fora isso, boa parte do React Router se resume a saber de onde vem cada dado: useParams para o que está no caminho, useSearchParams para o que está na query.

Fontes e Referências

Exercícios

Exercício 1

A aplicação funciona em desenvolvimento. Depois do deploy, navegar pelos links funciona — mas apertar F5 em /produtos/42 devolve 404. Por quê?

# o build gerado
dist/
├── index.html
└── assets/
    ├── index-a3f8c2.js
    └── index-b1e4d9.css
Ver resposta

✓ Resposta: Porque não existe nenhum arquivo em /produtos/42. Quando o usuário navega dentro da aplicação, quem troca a URL é o JavaScript, usando a History API — o servidor nem fica sabendo. Ao apertar F5, é o navegador que pede /produtos/42 ao servidor, e o servidor procura literalmente esse caminho no disco: só há index.html e a pasta de assets, então ele responde 404. A correção é de configuração de servidor, não de código: toda rota que não corresponda a um arquivo real deve devolver o index.html, e aí o React Router lê a URL e renderiza a tela certa. É o try_files $uri $uri/ /index.html no Nginx, o rewrites no vercel.json, a linha /* /index.html 200 no _redirects do Netlify. Em desenvolvimento isso passa despercebido porque o servidor do Vite já faz esse fallback por padrão — o defeito nasce exatamente na passagem para produção. Uma alternativa é trocar o BrowserRouter pelo HashRouter, que põe a rota depois de um # e nunca chega ao servidor; funciona em qualquer hospedagem estática, ao custo de URLs feias e de perder indexação decente pelos buscadores.

Exercício 2

Estas rotas estão nesta ordem. O que acontece ao acessar /produtos/novo — e a resposta seria a mesma num roteador de servidor como o Express?

<Routes>
  <Route path="/produtos/:id" element={<DetalheProduto />} />
  <Route path="/produtos/novo" element={<NovoProduto />} />
</Routes>
Ver resposta

✓ Resposta: No React Router, renderiza NovoProduto — o correto —, e a ordem de declaração não importa. A partir da versão 6 ele não testa as rotas uma a uma na ordem escrita: calcula uma pontuação de especificidade para cada padrão e escolhe a melhor, e um segmento literal como novo sempre pontua mais que um parâmetro como :id. No Express, a resposta é outra: ele percorre as rotas na ordem em que foram registradas e para na primeira que casa, então /produtos/:id venceria, req.params.id receberia a string "novo", e o resultado seria um 404 de "produto não encontrado" — exatamente o defeito visto no artigo do Express. A diferença entre os dois é fonte de confusão real em quem trabalha nas duas pontas, e vale guardar a regra de cada lado: no servidor, ordene do mais específico para o mais genérico; no React Router, confie no ranking. Há uma exceção que continua dependendo de ordem mesmo no React Router: rotas dentro do mesmo nível com o mesmo grau de especificidade, caso em que a primeira declarada vence. E o path="*" do 404 pode ficar em qualquer lugar, porque é o padrão de menor pontuação possível.

Exercício 3

O usuário tentou abrir /relatorios sem estar logado, foi mandado para o login, entrou — e caiu no /dashboard. O código guardou a rota pretendida. Onde ela se perdeu?

// RotaProtegida
if (!estaLogado) {
  return <Navigate to="/login" state={{ de: location.pathname }} replace />;
}

// Login
async function entrar(dados) {
  await login(dados);
  navigate('/dashboard');
}
Ver resposta

✓ Resposta: A rota foi guardada e ninguém a leu. O Navigate faz a parte dele, anexando { de: '/relatorios' } ao estado da navegação; o componente de login, porém, manda todo mundo para /dashboard sem consultar esse estado. A correção é ler com useLocation e usar o valor guardado como destino: const { state } = useLocation() e depois navigate(state?.de || '/dashboard', { replace: true }). O replace importa aqui: sem ele, a página de login fica no histórico, e o botão "voltar" do navegador devolve o usuário recém-autenticado para a tela de login — que, com a RotaPublica do artigo, o empurra de volta adiante, criando um vaivém confuso. Vale notar duas limitações do state de navegação, porque elas surpreendem: ele vive no histórico do navegador, então não sobrevive a um F5 com a URL do login nem pode ser colado em outra aba — se o destino precisar sobreviver a isso, o lugar dele é a query string, como /login?redirecionar=/relatorios. E, se for query string, o valor precisa ser validado antes do navigate: aceitar qualquer coisa vinda da URL como destino é a brecha clássica de open redirect, em que um link montado leva a vítima a um domínio de terceiros logo após o login.

Exercício 4

O lazy foi aplicado para reduzir o bundle. A aplicação quebra com "A component suspended while responding to synchronous input" ou mostra tela branca. O que falta?

import { lazy } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Relatorios = lazy(() => import('./pages/Relatorios'));

export default function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/relatorios" element={<Relatorios />} />
    </Routes>
  );
}
Ver resposta

✓ Resposta: Falta o <Suspense>. O lazy transforma o componente em algo que só existe depois que o navegador baixar o pedaço correspondente do bundle; enquanto isso, ele "suspende" a renderização, e o React precisa de alguém que diga o que mostrar nesse intervalo. Sem um limite de suspensão acima na árvore, não há o que renderizar e o resultado é o erro ou a tela em branco. A correção é envolver as rotas: <Suspense fallback={<Carregando />}><Routes>…</Routes></Suspense>. Três observações que mudam o resultado na prática. A primeira é onde colocar o Suspense: envolvendo tudo, a tela inteira pisca a cada troca de rota; envolvendo apenas o Outlet dentro do layout, o cabeçalho e o menu permanecem e só o conteúdo mostra o indicador — bem melhor. A segunda é que o carregamento pode falhar, por rede caída ou por um deploy novo que apagou o arquivo antigo, e o Suspense não trata erro: para isso é preciso um error boundary ao redor, senão a aplicação quebra de vez. A terceira é que dividir demais piora: cada rota vira uma requisição extra, e para telas pequenas o ganho não paga a espera — o alvo do lazy são as rotas pesadas, aquelas que carregam biblioteca de gráfico, editor de texto ou mapa.

Exercício 5

Os dois trechos navegam para a mesma tela. Qual a diferença para o usuário — e qual dos dois quebra o aplicativo?

// A
<a href="/produtos">Produtos</a>

// B
<Link to="/produtos">Produtos</Link>
Ver resposta

✓ Resposta: O A recarrega a página inteira. Um link comum entrega a navegação ao navegador: ele descarta o documento atual, pede tudo de novo ao servidor, reexecuta o JavaScript do zero e remonta a aplicação. O efeito visível é o branco entre uma tela e outra, e o invisível é pior — todo o estado em memória é perdido: carrinho não persistido, formulário meio preenchido, cache do React Query, posição de rolagem. O Link do B intercepta o clique, chama a History API para trocar a URL sem requisição, e deixa o React Router renderizar a tela nova; o estado continua vivo e a transição é instantânea. Daí a regra: dentro da aplicação, sempre Link ou NavLink; para sair dela, <a> mesmo. Vale conhecer a diferença entre os dois componentes do Router: o NavLink é um Link que sabe se está ativo, e por isso aceita função em className e style — é o que se usa em menu. E vale o alerta sobre a prop end: sem ela, a rota / fica marcada como ativa em todas as páginas, porque toda URL começa com barra; é o defeito mais comum de menu no React Router.

Comentários

Mais em Javascript

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

Um array na memória some quando o servidor reinicia, e é aí que entra o banco…

Testes automatizados com Jest
Testes automatizados com Jest

Mexer num código que estava funcionando e quebrar outra coisa é o problema que…

Promises: resolvendo o Callback Hell
Promises: resolvendo o Callback Hell

Uma Promise é o recibo de uma operação que ainda não terminou: você guarda o…