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

Criando um servidor HTTP com Node.js puro
Criando um servidor HTTP com Node.js puro

Um require de http, uma função de dois argumentos, e o servidor está de pé. O…

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

O fim da série reúne tudo numa aplicação de produção: React com rotas e…

Revisão + Projeto: API tipada e testada
Revisão + Projeto: API tipada e testada

Os quatro artigos do módulo se encontram num projeto só: uma API de produtos…