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
- React Router v6 — Documentação oficial: https://reactrouter.com/home
- React Router — Tutorial: https://reactrouter.com/tutorials/address-book
- React Router — Rotas protegidas: https://reactrouter.com/start/data/routing
- React — lazy e Suspense: https://react.dev/reference/react/lazy
- Vite — Code Splitting: https://vitejs.dev/guide/build.html#chunking-strategy
- React Router — rotas no modo framework: https://reactrouter.com/start/framework/routing
- Full Stack React — Anthony Accomazzo (Fullstack.io)
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.