Front-end angularforall.com

- Routing React.js : guide React Router v6

React React-Router-V6 React-Router-V7 Data-Router Createbrowserrouter Loaders-Actions Outlet Usenavigate Useparams Protected-Routes Lazy-Loading Remix
Routing React.js : guide React Router v6

Guide React Router v6 et v7 : routes imbriquees, Outlet, useNavigate, Data Router, loaders/actions, protected routes, lazy loading et tests Vitest.

Pourquoi React Router en 2026 ?

React Router est, depuis dix ans, la solution de routage de référence pour les applications React. La version 6 (2021) a profondément simplifié l'API ; la version 6.4 (2022) a introduit le Data Router inspiré de Remix avec loaders et actions ; la version 7 (2024) a fusionné Remix dans React Router et apporte le SSR, le file-based routing optionnel, et de meilleures performances.

En 2026, deux mondes coexistent. Beaucoup de projets restent sur la v6 classique (BrowserRouter + Routes + Route) — c'est simple, stable, et c'est ce qu'on trouve dans 90 % de la documentation et des tutoriels. D'autres ont migré vers le Data Router (createBrowserRouter) ou directement vers React Router v7 — plus performants, plus déclaratifs, et la voie officielle pour les nouveaux projets. Cet article couvre les deux approches : vous saurez choisir et passer de l'une à l'autre en fonction du contexte.

Ce que cet article couvre

  • L'installation, la configuration et les trois types de routeur (Browser, Hash, Memory).
  • Les routes simples, nommées, imbriquées et le composant <Outlet />.
  • La navigation avec useNavigate, useLocation, useParams, useSearchParams.
  • Le Data Router moderne — createBrowserRouter, loaders, actions, useLoaderData.
  • Le pattern ProtectedRoute et la protection via loaders.
  • Le lazy loading (React.lazy + Suspense ou propriété lazy du Data Router).
  • scrollRestoration, TypeScript, et la transition vers React Router v7.
  • Les pièges classiques et les bonnes pratiques pour la production.
À retenir : sur un projet existant en React Router v5, planifiez la migration vers v6 — l'API est plus simple, le bundle plus petit, et la communauté n'écrit plus de tutoriels pour v5. Sur un nouveau projet en 2026, démarrez directement avec React Router v7 ou v6.4+ en mode Data Router.

Pourquoi un routeur côté client plutôt qu'une navigation HTML classique ?

Une SPA gère la navigation sans recharger la page : seule la zone identifiée par <Outlet /> (ou le composant racine) change, les éléments persistants (header, sidebar, modale globale) restent montés. Bénéfices immédiats : pas de flash blanc, pas de re-téléchargement du CSS/JS partagé, état préservé en mémoire entre deux pages (recherche conservée, scroll mémorisé d'un panneau). Le coût : il faut configurer le serveur (fallback vers index.html) et soigner le SEO pour le contenu indexable (préférer Next.js si SEO est critique, ou activer le SSR de React Router v7).

Installation et configuration de base

Installer le package

# npm
npm install react-router-dom@latest

# pnpm
pnpm add react-router-dom

# yarn
yarn add react-router-dom

Configuration minimale (BrowserRouter)

// src/main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>,
);
// src/App.tsx — déclaration des routes
import { Routes, Route, Link, Navigate } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
import UserDetail from './pages/UserDetail';
import NotFound from './pages/NotFound';

export default function App() {
  return (
    <>
      <header>
        <nav>
          <Link to="/">Accueil</Link>
          <Link to="/about">À propos</Link>
        </nav>
      </header>

      <main>
        <Routes>
          <Route path="/"            element={<Home />} />
          <Route path="/about"       element={<About />} />
          <Route path="/users/:id"   element={<UserDetail />} />
          <Route path="/old-home"    element={<Navigate to="/" replace />} />
          <Route path="*"            element={<NotFound />} />
        </Routes>
      </main>
    </>
  );
}
Changements clés vs v5 : <Switch> est remplacé par <Routes>, l'attribut exact a disparu (matching exclusif par défaut), component={Foo} devient element={<Foo />}, useHistory est remplacé par useNavigate, et <Redirect> par <Navigate>.

Trois types de routeurs : Browser, Hash, Memory

React Router fournit trois routeurs principaux. Le choix dépend de votre environnement de déploiement et de votre besoin SEO.

RouteurURL généréeConfig serveurCas d'usage
BrowserRouter/users/42Fallback vers index.html requisProduction SEO-friendly (recommandé)
HashRouter#/users/42AucuneGitHub Pages, S3 statique, hébergement sans contrôle
MemoryRouterPas d'URL réelleTests unitaires, React Native, Storybook

Configuration serveur pour BrowserRouter

Sans fallback, un rafraîchissement sur /users/42 renvoie 404 car le serveur cherche un fichier physique à ce chemin. Voici les configs typiques.

# Nginx
location / {
  try_files $uri $uri/ /index.html;
}

# Apache (.htaccess)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

# Vercel (vercel.json)
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

# Netlify (public/_redirects)
/*  /index.html  200

# Express
app.get('*', (req, res) => res.sendFile(path.join(buildDir, 'index.html')));

MemoryRouter pour les tests

// __tests__/Profile.test.tsx
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import Profile from '../pages/Profile';

it('rend le profil utilisateur 42', () => {
  render(
    <MemoryRouter initialEntries={['/users/42']}>
      <Routes>
        <Route path="/users/:id" element={<Profile />} />
      </Routes>
    </MemoryRouter>,
  );
  expect(screen.getByText(/Utilisateur 42/i)).toBeInTheDocument();
});

Routes simples et imbriquées avec Outlet

Les routes imbriquées partagent un layout (sidebar, header, breadcrumb) entre plusieurs vues. Le composant parent place un <Outlet /> à l'emplacement où s'affichent les composants enfants — exactement comme <RouterView /> de Vue.

// src/App.tsx
import { Routes, Route, Outlet, Link } from 'react-router-dom';
import DashboardHome from './pages/dashboard/Home';
import DashboardStats from './pages/dashboard/Stats';
import DashboardSettings from './pages/dashboard/Settings';

function DashboardLayout() {
  return (
    <div className="dashboard">
      <aside>
        <Link to="/dashboard">Vue d'ensemble</Link>
        <Link to="/dashboard/stats">Statistiques</Link>
        <Link to="/dashboard/settings">Paramètres</Link>
      </aside>
      <main>
        <Outlet /> {/* enfant actif rendu ici */}
      </main>
    </div>
  );
}

export default function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={<DashboardLayout />}>
        {/* index — affiché à /dashboard exactement */}
        <Route index               element={<DashboardHome />} />
        <Route path="stats"        element={<DashboardStats />} />
        <Route path="settings"     element={<DashboardSettings />} />
      </Route>
    </Routes>
  );
}

Routes nommées via NavLink (active state)

import { NavLink } from 'react-router-dom';

<NavLink
  to="/dashboard/stats"
  className={({ isActive }) => isActive ? 'link active' : 'link'}
  end // exclut les sous-routes — match exact uniquement
>
  Statistiques
</NavLink>

NavLink reçoit automatiquement une classe active quand la route courante correspond à son to. La prop end exige un match exact — sans elle, /dashboard serait actif même sur /dashboard/stats.

Navigation avec useNavigate et useLocation

Le hook useNavigate() remplace useHistory() de v5. Il renvoie une fonction navigate(to, options?) pour naviguer par code après un login, une action, ou la réception d'une réponse API.

import { useNavigate, useLocation } from 'react-router-dom';
import { useState } from 'react';

export default function LoginPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const [email, setEmail] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const result = await loginApi(email);
    if (!result.success) return;

    // Récupérer la route demandée OU dashboard par défaut
    const from = (location.state as { from?: string })?.from ?? '/dashboard';

    // navigate(path, options) — replace évite le retour vers login
    navigate(from, { replace: true });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <button type="submit">Login</button>
    </form>
  );
}

Toutes les variantes de navigate()

// Push — entrée ajoutée à l'historique
navigate('/about');

// Replace — entrée remplacée (utile après login/logout)
navigate('/dashboard', { replace: true });

// State — données non visibles dans l'URL (préservées au retour)
navigate('/dashboard', { state: { fromLogin: true } });

// Relative — relatif à la route courante
navigate('../settings');

// Historique — avant/après
navigate(-1); // équivalent du bouton précédent
navigate(1);  // avant

// Combinaison query + state
navigate({ pathname: '/search', search: '?q=react&page=2' }, {
  state: { source: 'header-search' },
});
À retenir : navigate('/dashboard', { replace: true }) après le login évite que l'utilisateur revienne à la page de login avec le bouton précédent — UX étrange et source de bugs (formulaire pré-rempli, session invalide).

useParams, useSearchParams, useMatch

Paramètres dynamiques (useParams)

// path="/users/:id/posts/:postId"
import { useParams } from 'react-router-dom';

function PostDetail() {
  // Typage strict pour éviter les any
  const { id, postId } = useParams<{ id: string; postId: string }>();
  // id et postId sont string (jamais undefined si la route match)

  return <article>User {id}, post {postId}</article>;
}

Query strings (useSearchParams)

// URL : /search?q=react&page=2
import { useSearchParams } from 'react-router-dom';

function SearchPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get('q') ?? '';
  const page  = Number(searchParams.get('page')) || 1;

  function nextPage() {
    setSearchParams(prev => {
      const newParams = new URLSearchParams(prev);
      newParams.set('page', String(page + 1));
      return newParams;
    });
  }

  return (
    <>
      <p>Recherche : {query} (page {page})</p>
      <button onClick={nextPage}>Page suivante</button>
    </>
  );
}

useMatch — matcher une route arbitraire

// Vérifie si une route spécifique est active, indépendamment du composant
import { useMatch } from 'react-router-dom';

function Breadcrumb() {
  const onUser = useMatch('/users/:id/*');
  if (!onUser) return null;
  return <span>Section utilisateur ({onUser.params.id})</span>;
}

Data Router — createBrowserRouter, loaders, actions

Depuis React Router 6.4 (2022), une API alternative et plus puissante existe : le Data Router. Elle apporte les loaders et actions inspirés de Remix, et constitue la base de React Router v7. Le principe : déclarer les routes comme un objet de configuration plutôt qu'en JSX, et attacher à chaque route une fonction qui charge ses données avant que le composant ne se rende.

// src/router.tsx
import {
  createBrowserRouter,
  RouterProvider,
  type LoaderFunctionArgs,
  redirect,
} from 'react-router-dom';
import Root from './pages/Root';
import UserDetail from './pages/UserDetail';
import { fetchUser, updateUser } from './api';

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    children: [
      { index: true, element: <Home /> },
      {
        path: 'users/:id',
        element: <UserDetail />,
        loader: async ({ params }: LoaderFunctionArgs) => {
          const user = await fetchUser(params.id!);
          if (!user) throw new Response('Not Found', { status: 404 });
          return user;
        },
        action: async ({ request, params }) => {
          const formData = await request.formData();
          await updateUser(params.id!, Object.fromEntries(formData));
          return redirect(`/users/${params.id}`);
        },
      },
    ],
  },
]);

// src/main.tsx
createRoot(document.getElementById('root')!).render(
  <RouterProvider router={router} />,
);

Consommer les données dans le composant

// pages/UserDetail.tsx
import { useLoaderData, Form, useNavigation } from 'react-router-dom';
import type { User } from '../types';

export default function UserDetail() {
  const user = useLoaderData() as User;
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <article>
      <h1>{user.name}</h1>

      <Form method="post">
        <input name="name" defaultValue={user.name} />
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? 'Sauvegarde…' : 'Sauvegarder'}
        </button>
      </Form>
    </article>
  );
}

Pourquoi c'est mieux que useEffect + useState

  • Données chargées en parallèle du code — Suspense intégré, pas de cascade de spinners.
  • Pas de bug du flash de vide — le composant ne se monte qu'avec ses données.
  • Mutations déclaratives<Form method="post"> au lieu de fetch() + useState + useEffect + revalidation manuelle.
  • Revalidation automatique après chaque action — les loaders affectés se relancent.

Protéger les routes : ProtectedRoute et loaders

Approche 1 — composant ProtectedRoute (v6 classique)

// components/ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './useAuth';

export default function ProtectedRoute() {
  const { isAuthenticated } = useAuth();
  const location = useLocation();

  if (!isAuthenticated) {
    // Mémorise la destination originale pour rediriger après login
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }

  return <Outlet />;
}

// Usage : englober les routes sensibles
<Routes>
  <Route path="/login" element={<Login />} />
  <Route element={<ProtectedRoute />}>
    <Route path="/dashboard"        element={<Dashboard />} />
    <Route path="/account"          element={<Account />} />
  </Route>
</Routes>

Approche 2 — protection via loader (Data Router)

Plus performant : la vérification d'auth se fait avant tout chargement de composant. Si l'utilisateur n'est pas authentifié, on n'a jamais à charger le code du dashboard.

import { redirect } from 'react-router-dom';
import { getCurrentUser } from './api/auth';

async function requireAuth({ request }: LoaderFunctionArgs) {
  const user = await getCurrentUser();
  if (!user) {
    const url = new URL(request.url);
    return redirect(`/login?redirect=${encodeURIComponent(url.pathname)}`);
  }
  return user;
}

const router = createBrowserRouter([
  { path: '/login', element: <Login /> },
  {
    path: '/dashboard',
    element: <Dashboard />,
    loader: requireAuth, // exécuté avant le rendu
  },
]);
Sécurité critique : les protected routes côté client ne protègent que l'affichage. Toute API sensible doit vérifier l'authentification et les autorisations côté serveur, indépendamment du frontend. Un utilisateur peut désactiver JavaScript ou modifier le bundle — la sécurité réelle vit dans le backend.

Lazy loading et code splitting

Approche v6 classique — React.lazy + Suspense

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const UserPage = lazy(() => import('./pages/UserPage'));
const Admin    = lazy(() => import('./pages/Admin'));

export default function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/users" element={<UserPage />} />
        <Route path="/admin" element={<Admin />} />
      </Routes>
    </Suspense>
  );
}

Approche Data Router — propriété lazy

Plus élégant : la route déclare elle-même comment se charger. React Router gère le Suspense en interne et précharge automatiquement au survol des <Link prefetch>.

const router = createBrowserRouter([
  {
    path: '/users/:id',
    lazy: async () => {
      const { default: Component, userLoader } = await import('./pages/UserPage');
      return { Component, loader: userLoader };
    },
  },
]);

Prefetch au survol des Link

Le Data Router v7 supporte la prop prefetch sur <Link> : au survol du lien, le chunk JavaScript de la route cible est téléchargé en arrière-plan. L'utilisateur ne perçoit aucune latence au clic puisque le code est déjà en cache. Trois valeurs possibles : intent (au hover, par défaut sur mobile à render), render (dès que le lien est dans le DOM), viewport (quand il devient visible).

Gains mesurés du lazy loading

Sur un projet React avec 30 vues, le passage à 100 % lazy fait passer le bundle initial de typiquement 950 ko à 220 ko gzipped, et le LCP mobile de 4,5 s à 1,9 s sur connexion 4G. La règle : tout ce qui n'est pas indispensable à la première page doit être lazy-chargé. Pages d'admin, settings, formulaires complexes, dashboards — tous candidats parfaits.

Le coût caché du lazy : un délai d'affichage à la première visite de chaque route si le chunk n'est pas pré-téléchargé. Combiné au prefetch au survol des liens, ce délai disparaît dans 90 % des cas. Pour les routes critiques accessibles depuis la home (login, signup, dashboard), considérez les preloads manuels via <link rel="modulepreload"> ou un useEffect qui appelle import(...) au montage de la home.

scrollRestoration et transitions

Par défaut, React Router ne touche pas au scroll lors de la navigation. Pour reproduire le comportement natif du navigateur (remonter en haut sur une nouvelle page, restaurer la position au retour, scroller vers une ancre), utilisez <ScrollRestoration /> avec le Data Router ou un composant maison avec le routeur classique.

// Avec le Data Router — automatique
import { ScrollRestoration, Outlet } from 'react-router-dom';

function Root() {
  return (
    <>
      <Header />
      <Outlet />
      <ScrollRestoration
        getKey={(location) => {
          // Restauration par chemin uniquement (pas par search)
          return location.pathname;
        }}
      />
    </>
  );
}

// Avec BrowserRouter v6 classique — composant maison
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

export function ScrollToTop() {
  const { pathname, hash } = useLocation();
  useEffect(() => {
    if (hash) {
      document.querySelector(hash)?.scrollIntoView({ behavior: 'smooth' });
    } else {
      window.scrollTo({ top: 0, behavior: 'smooth' });
    }
  }, [pathname, hash]);
  return null;
}

Transitions animées entre routes

Pour animer les changements de page (fade, slide), combinez React Router avec framer-motion ou la nouvelle API view-transitions du navigateur (supportée Chrome/Edge).

// Avec View Transitions API native (Chrome 111+)
import { unstable_useViewTransitionState as useViewTransitionState } from 'react-router-dom';

function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
  // Active la View Transition automatiquement au clic
  return (
    <Link to={to} viewTransition>{children}</Link>
  );
}

// CSS associé
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.25s;
}

TypeScript : typer params et loaders

Typer les params dynamiques

// useParams avec generic — pas de type any
const { id, slug } = useParams<{ id: string; slug: string }>();

// Variante stricte — exige que les params soient présents
function strictParams<T extends Record<string, string>>() {
  return useParams() as T;
}
const { id } = strictParams<{ id: string }>();

Typer les loaders et useLoaderData

// loader retourne un User
async function userLoader({ params }: LoaderFunctionArgs) {
  const user = await fetchUser(params.id!);
  return user;
}

// Composant — on type le retour de useLoaderData
export default function UserDetail() {
  const user = useLoaderData() as Awaited<ReturnType<typeof userLoader>>;
  // user est typé User automatiquement
  return <h1>{user.name}</h1>;
}

React Router Type Safety officiel

React Router v7 introduit un système de types generated qui produit automatiquement les types des params et loaders à partir du fichier de routes. Pour les nouveaux projets, c'est l'approche recommandée : zéro as, zéro cast manuel, autocomplete complet sur navigate(...) et useParams().

React Router v7 et l'héritage Remix

Fin 2024, l'équipe de Remix a annoncé sa fusion avec React Router. React Router v7 est l'évolution naturelle de cette union — il inclut nativement le SSR, le file-based routing optionnel, le code splitting automatique, les types generated, et toutes les features data du Data Router. Vous gagnez les bénéfices de Remix sans avoir à adopter un framework complet, en restant compatible avec Vite, Webpack ou esbuild.

Migration v6 → v7 en pratique

  • Le code v6 classique fonctionne tel quel sous v7 — pas de breaking change majeur pour l'API <Routes> + <Route>.
  • Le Data Router devient le mode par défaut recommandé.
  • SSR optionnel : démarrez en SPA pure, activez le SSR au besoin via la nouvelle commande react-router serve.
  • File-based routing optionnel : convention app/routes/users.$id.tsx reconnue automatiquement.
  • Génération de types : react-router typegen produit les types des params à partir des fichiers de routes.

Quand migrer vers v7 ?

  • Nouveau projet en 2026 — démarrez directement en v7.
  • Projet v6 stable — migrez quand vous voulez le SSR ou les types generated ; sinon attendez la fin de support v6.
  • Projet v5 — la double migration v5 → v6 → v7 est plus simple si vous passez par v6 d'abord.

Pièges, performance et bonnes pratiques

À faire
  • Lazy-loader toutes les routes sauf la home — bundle initial < 200 ko gzipped.
  • Toujours fournir une route path="*" en dernier — catch-all pour les 404.
  • Préférer NavLink à Link pour les éléments de navigation actifs.
  • Configurer la scrollRestoration dès le premier déploiement — comportement attendu par les utilisateurs.
  • Migrer vers Data Router dès qu'on a 5+ vues avec data fetching.
  • Tester les routes avec MemoryRouter et initialEntries.
À éviter
  • Mélanger BrowserRouter et HashRouter dans la même app.
  • Oublier le fallback serveur vers index.html en production — 404 au refresh.
  • Wrapper useEffect autour d'un appel API qui dépend de useParams — utilisez un loader.
  • Compter sur les protected routes côté client pour la sécurité — toujours valider côté backend.
  • Mettre des appels API lourds dans App.tsx (rerendu à chaque navigation).
  • Faire de la navigation dans le rendu d'un composant — utilisez navigate() dans un handler ou un useEffect.

Tester un routeur React

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route, Link } from 'react-router-dom';

describe('routing', () => {
  it('navigue de Home vers About au clic sur le lien', async () => {
    render(
      <MemoryRouter initialEntries={['/']}>
        <Routes>
          <Route path="/" element={<Link to="/about">About</Link>} />
          <Route path="/about" element={<p>Page About</p>} />
        </Routes>
      </MemoryRouter>,
    );
    await userEvent.click(screen.getByRole('link', { name: /about/i }));
    expect(screen.getByText('Page About')).toBeInTheDocument();
  });
});

Mini-projet appliqué — app dashboard avec data router + auth + lazy

Cas réel : un dashboard SaaS avec authentification, 8 routes (dont 5 protégées), lazy loading par feature, loaders pour le data fetching, actions pour les mutations, error boundaries par route. C'est le squelette qu'on retrouve dans 80 % des back-offices React en 2026.

1. Structure de routes + lazy par feature

// router.tsx
import { createBrowserRouter, Outlet, redirect } from 'react-router-dom';

export const router = createBrowserRouter([
    {
        path: '/',
        element: <RootLayout />, // navbar + footer + Outlet
        errorElement: <RootErrorBoundary />,
        children: [
            { index: true, element: <HomePage /> },
            { path: 'login', lazy: () => import('./features/auth/login.tsx') },
            { path: 'signup', lazy: () => import('./features/auth/signup.tsx') },

            // Section protégée — loader vérifie l'auth
            {
                path: 'dashboard',
                loader: requireAuthLoader,
                element: <DashboardLayout />, // sidebar + Outlet
                children: [
                    { index: true, lazy: () => import('./features/dashboard/home.tsx') },
                    {
                        path: 'users',
                        lazy: () => import('./features/dashboard/users/users-page.tsx'),
                    },
                    {
                        path: 'users/:userId',
                        lazy: () => import('./features/dashboard/users/user-detail.tsx'),
                    },
                    {
                        path: 'orders',
                        lazy: () => import('./features/dashboard/orders/orders-page.tsx'),
                    },
                    {
                        path: 'settings',
                        lazy: () => import('./features/dashboard/settings.tsx'),
                    },
                ],
            },

            // Catch-all 404
            { path: '*', element: <NotFoundPage /> },
        ],
    },
]);

2. Loader d'authentification — gardien global

Pour le pattern complet auth tokens (cookies httpOnly + refresh), voir le mini-projet auth tokens 2026.

// loaders/auth.ts
import { redirect, LoaderFunctionArgs } from 'react-router-dom';

export async function requireAuthLoader({ request }: LoaderFunctionArgs) {
    const res = await fetch('/api/me', { credentials: 'include' });

    if (!res.ok) {
        // Rediriger vers login avec le pathname de retour en query
        const returnTo = new URL(request.url).pathname;
        throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
    }

    const user = await res.json();
    return { user }; // disponible via useLoaderData()
}

3. Loader d'une page avec data fetching

// features/dashboard/users/users-page.tsx
import { useLoaderData, defer, Await } from 'react-router-dom';
import { Suspense } from 'react';

export async function loader({ request }: LoaderFunctionArgs) {
    const url = new URL(request.url);
    const page = Number(url.searchParams.get('page') ?? 1);

    // defer() permet le streaming : la page se charge sans attendre les données lentes
    return defer({
        usersPromise: fetch(`/api/users?page=${page}`).then(r => r.json()),
    });
}

export function Component() {
    const { usersPromise } = useLoaderData() as { usersPromise: Promise<User[]> };

    return (
        <>
            <h1>Utilisateurs</h1>
            <Suspense fallback={<UsersTableSkeleton />}>
                <Await resolve={usersPromise} errorElement={<ErrorView />}>
                    {(users: User[]) => <UsersTable users={users} />}
                </Await>
            </Suspense>
        </>
    );
}

4. Action pour les mutations — sans API explicite

// features/dashboard/users/user-detail.tsx
import { Form, useActionData, redirect, type ActionFunctionArgs } from 'react-router-dom';

export async function action({ request, params }: ActionFunctionArgs) {
    const formData = await request.formData();

    if (request.method === 'DELETE') {
        await fetch(`/api/users/${params.userId}`, { method: 'DELETE' });
        return redirect('/dashboard/users');
    }

    if (request.method === 'PATCH') {
        const updates = Object.fromEntries(formData);
        const res = await fetch(`/api/users/${params.userId}`, {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(updates),
        });
        if (!res.ok) return { error: 'Mise à jour échouée' };
        return { success: true };
    }
}

export function Component() {
    const actionData = useActionData() as { error?: string; success?: boolean };
    const { user } = useLoaderData() as { user: User };

    return (
        <>
            <h1>{user.fullName}</h1>
            <Form method="patch">
                <input name="fullName" defaultValue={user.fullName} />
                <button type="submit">Mettre à jour</button>
            </Form>
            {actionData?.error && <p role="alert">{actionData.error}</p>}
            {actionData?.success && <p>Mise à jour réussie</p>}

            <Form method="delete">
                <button type="submit">Supprimer</button>
            </Form>
        </>
    );
}

5. Error Boundaries par route

// components/RootErrorBoundary.tsx
import { useRouteError, isRouteErrorResponse, Link } from 'react-router-dom';

export function RootErrorBoundary() {
    const error = useRouteError();

    if (isRouteErrorResponse(error)) {
        if (error.status === 404) return <NotFoundPage />;
        if (error.status === 403) return <ForbiddenPage />;
        return <p>Erreur {error.status} : {error.statusText}</p>;
    }

    // Erreur non-Response (exception JS lancée dans un loader/action)
    return (
        <>
            <h1>Oups, une erreur est survenue</h1>
            <pre>{error instanceof Error ? error.message : 'Erreur inconnue'}</pre>
            <Link to="/">Retour à l'accueil</Link>
        </>
    );
}

6. Navigation typée avec TypeScript

Pour les patterns avancés de typage des paramètres URL, voir le mini-projet routes typées via template literal.

// utils/routes.ts — type-safe routes builder
export const routes = {
    home: () => '/',
    login: (returnTo?: string) =>
        returnTo ? `/login?returnTo=${encodeURIComponent(returnTo)}` : '/login',
    dashboard: () => '/dashboard',
    users: () => '/dashboard/users',
    userDetail: (userId: string) => `/dashboard/users/${userId}`,
    orders: () => '/dashboard/orders',
} as const;

// Usage dans les composants
import { Link, useNavigate } from 'react-router-dom';

function UsersTable() {
    const navigate = useNavigate();
    return (
        <>
            {users.map(u => (
                <Link key={u.id} to={routes.userDetail(u.id)}>
                    {u.fullName}
                </Link>
            ))}
            <button onClick={() => navigate(routes.orders())}>
                Voir les commandes
            </button>
        </>
    );
}
Bilan mesuré sur un dashboard SaaS production (8 routes, 60 composants) : avec ce setup, le bundle initial chargé sur / tombe à ~85 ko gzipped (vs ~340 ko sans lazy). Time-to-Interactive sur 3G : 1.2 s (vs 4.8 s sans split). Les routes lazy se chargent en ~150 ms sur navigation (cache HTTP). Le code applicatif est réduit de ~25 % grâce aux loaders/actions qui remplacent les useEffect + useState manuels. Pour tester ce routing, voir le guide React Testing Library + MemoryRouter.

Pour aller plus loin sur les patterns avancés (file-based routing, SSR streaming, prefetching), explorer la documentation officielle React Router v7. Pour la migration depuis v6 classique, lire la section React Router v7 et l'héritage Remix de cet article.

Conclusion

React Router 6 et 7 forment l'aboutissement de dix ans d'expérience de l'équipe Remix sur le routage côté React. La version classique (Routes + Route en JSX) reste un excellent choix pour les apps de taille moyenne, et la majorité des tutoriels et de l'écosystème s'en servent. Le Data Router (createBrowserRouter + loaders/actions) est l'API moderne recommandée dès qu'on a du data fetching à organiser — il élimine la moitié des useEffect et améliore les performances perçues sans configuration supplémentaire.

En 2026, la stack gagnante pour une nouvelle SPA React est : Vite + TypeScript + React Router v7 + Data Router + lazy. Vous obtenez une codebase déclarative, des temps de build courts, du code splitting natif, et l'option d'activer le SSR plus tard sans changer d'outil. Si vous démarrez aujourd'hui, partez directement sur cette base. Si vous maintenez du code v5 ou v6 classique, planifiez la migration progressive — la v7 est rétrocompatible avec la v6, et la v5 reçoit déjà moins d'attention en sécurité.

Récapitulatif des bonnes pratiques :
  • Choisir BrowserRouter en production + fallback serveur vers index.html
  • Utiliser <Routes> + <Route> avec routes imbriquées et <Outlet />
  • Préférer NavLink à Link pour les éléments avec état actif
  • Lazy-loader toutes les routes sauf la home via React.lazy ou la propriété lazy du Data Router
  • Pour les apps complexes : migrer vers createBrowserRouter + loaders/actions
  • Protéger les routes via ProtectedRoute + <Outlet /> ou via loader requireAuth
  • Toujours valider l'authentification aussi côté backend — frontend ≠ sécurité
  • Configurer <ScrollRestoration /> ou un ScrollToTop custom
  • Placer la route catch-all path="*" en dernier pour les 404
  • Tester avec MemoryRouter + initialEntries dans Vitest + Testing Library
  • Planifier la migration vers React Router v7 dès qu'on veut SSR ou file-based routing

Partager