31 Routing – Navigation in Single Page Applications

React-Anwendungen existieren in einem Browser-Tab als kontinuierlicher JavaScript-Prozess. Was wie eine Website mit vielen Seiten wirkt, ist technisch eine einzige HTML-Seite, deren Inhalt sich dynamisch verändert. Diese Architektur – Single Page Application – bringt enorme Vorteile für Responsivität und Benutzererfahrung, schafft aber ein fundamentales Problem: Wie bildet man verschiedene Anwendungszustände in der URL ab? Wie funktionieren Browser-Navigation, Bookmarks und das Teilen von Links?

React selbst kennt kein Routing. Die Bibliothek rendert Komponenten als Reaktion auf State-Änderungen, kümmert sich aber nicht um URLs. Hier setzt React Router an – die de-facto-Standard-Lösung für clientseitiges Routing, die URLs und Komponenten verbindet, ohne die Browser-History zu brechen oder das SPA-Paradigma zu kompromittieren.

React Router arbeitet nach einem Provider-Pattern. Der Router umschließt die gesamte Anwendung und stellt einen Kontext bereit, den alle untergeordneten Komponenten nutzen können. Dieser Kontext enthält die aktuelle URL, Navigationsmethoden und die Browser-History.

import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
        <Link to="/products">Products</Link>
      </nav>
      
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/products" element={<ProductList />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

Das Routes-Element ersetzt das ältere Switch aus React Router v5. Es implementiert einen intelligenten Matching-Algorithmus, der automatisch die spezifischste Route wählt. Die Reihenfolge der Route-Definitionen ist weniger kritisch als früher, nur Catch-All-Routen mit path="*" müssen am Ende stehen.

Link-Komponenten sehen aus wie HTML-Anker, verhalten sich aber fundamental anders. Ein Klick sendet keine HTTP-Anfrage an den Server. Stattdessen ändert React Router die Browser-URL programmatisch und rendert die passende Komponente. Das DOM wird aktualisiert, der JavaScript-Prozess läuft weiter – keine vollständige Seitenladung, keine flackernden Übergänge.

31.2 Router-Typen: Browser, Hash, Memory, Static

React Router bietet verschiedene Router-Implementierungen für unterschiedliche Deployment-Szenarien. Die Wahl des Routers ist eine Architekturentscheidung mit weitreichenden Konsequenzen für SEO, Server-Konfiguration und URL-Ästhetik.

31.2.1 BrowserRouter: Der moderne Standard

BrowserRouter nutzt die HTML5 History API für saubere URLs ohne technische Artefakte. Eine Route wie /products/electronics/laptops wird genau so in der Browser-Adresszeile angezeigt – keine Hashes, keine Query-Parameter-Tricks.

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

<BrowserRouter>
  <App />
</BrowserRouter>

Die History API bietet Methoden wie pushState() und replaceState(), die es ermöglichen, die URL zu ändern, ohne eine neue HTTP-Anfrage auszulösen. BrowserRouter nutzt diese Funktionen, um Navigation nahtlos zu implementieren.

Der Preis für saubere URLs ist Server-Konfiguration. Da alle Routen clientseitig verwaltet werden, muss der Server für jeden URL-Pfad dieselbe index.html ausliefern. Ohne diese Konfiguration führen direkte URL-Aufrufe oder Browser-Refreshs zu 404-Fehlern.

# Apache .htaccess
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>
# Nginx Konfiguration
location / {
  try_files $uri $uri/ /index.html;
}

31.2.2 HashRouter: Kompatibilität ohne Server-Konfiguration

HashRouter nutzt den Fragment-Identifier (#) für Routing. URLs sehen aus wie https://example.com/#/products/123. Alles nach dem Hash wird traditionell nicht an den Server gesendet – der Server sieht nur die Basis-URL.

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

<HashRouter>
  <App />
</HashRouter>

Das macht HashRouter ideal für statisches Hosting ohne Kontrolle über Server-Konfiguration: GitHub Pages, Netlify mit Default-Settings, S3-Buckets. Die Deployment-Komplexität sinkt drastisch, aber die URLs werden weniger elegant.

SEO ist ein weiterer Faktor. Moderne Suchmaschinen haben verbesserte Hash-Fragment-Unterstützung, aber BrowserRouter-URLs bleiben überlegen für Indexierung und Social-Media-Sharing.

Aspekt BrowserRouter HashRouter
URL-Format /products/123 /#/products/123
Server-Config nötig Ja Nein
SEO-Qualität Optimal Akzeptabel
Browser-Support Moderne Browser Alle Browser
Deployment-Komplexität Mittel Niedrig

31.2.3 MemoryRouter: Tests und Embedded-Szenarien

MemoryRouter verwaltet die gesamte Routing-History im JavaScript-Speicher, ohne Browser-URL-Integration. Die Adresszeile bleibt unverändert, Navigation ist komplett intern.

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

<MemoryRouter initialEntries={['/products', '/cart']} initialIndex={0}>
  <App />
</MemoryRouter>

Der primäre Anwendungsfall sind Unit-Tests. Router-abhängige Komponenten können isoliert getestet werden, ohne echte Browser-Navigation zu simulieren.

import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

test('renders product page', () => {
  render(
    <MemoryRouter initialEntries={['/products/123']}>
      <ProductDetail />
    </MemoryRouter>
  );
  
  expect(screen.getByText('Product Details')).toBeInTheDocument();
});

Ein zweiter Anwendungsfall sind eingebettete Widgets oder iframes, wo die Host-Seiten-URL nicht beeinflusst werden soll. Das Widget navigiert intern, die äußere Seite bleibt unberührt.

31.2.4 StaticRouter: Server-Side Rendering

StaticRouter ist für Server-Side Rendering konzipiert. Er rendert basierend auf einer bekannten URL, ohne Interaktivität oder Navigation zu unterstützen.

import { StaticRouter } from 'react-router-dom/server';

// Server-seitiger Code
app.get('*', (req, res) => {
  const html = renderToString(
    <StaticRouter location={req.url}>
      <App />
    </StaticRouter>
  );
  
  res.send(`<!DOCTYPE html><html>...${html}...</html>`);
});

Der Server generiert vollständig gerenderten HTML-String, schickt ihn zum Client, wo React die Kontrolle übernimmt (Hydration). Suchmaschinen erhalten complete HTML, Initial Page Load ist schneller, JavaScript-Fehler brechen nicht die gesamte Seite.

Moderne Frameworks wie Next.js abstrahieren diese Komplexität. Für custom SSR-Setups bleibt StaticRouter essentiell.

31.3 Route-Parameter: Dynamische URLs

Statische Routen reichen für “About Us” oder “Contact”. Realistische Anwendungen brauchen dynamische Segmente: Produkt-IDs, Benutzernamen, Kategorien. React Router bietet zwei komplementäre Mechanismen.

31.3.1 URL-Parameter: Strukturelle Identifikatoren

URL-Parameter werden durch Doppelpunkte markiert und schaffen Platzhalter für variable Werte. Eine Route /user/:userId matched /user/123 oder /user/anna-schmidt.

<Route path="/user/:userId" element={<UserProfile />} />
<Route path="/products/:category/:productId" element={<ProductDetail />} />

Der useParams-Hook extrahiert diese Werte:

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

interface UserParams {
  userId: string;
}

function UserProfile() {
  const { userId } = useParams<UserParams>();
  
  if (!userId) {
    return <Navigate to="/users" replace />;
  }
  
  // userId ist garantiert string
  const numericId = parseInt(userId, 10);
  
  if (isNaN(numericId)) {
    return <div>Ungültige User-ID</div>;
  }
  
  return <div>User Profile für ID: {numericId}</div>;
}

Wichtig: Alle Parameter sind Strings und können undefined sein, selbst wenn die Route-Definition sie als erforderlich markiert. Runtime-Validierung ist nicht optional.

interface ProductParams {
  category: string;
  productId: string;
}

function ProductDetail() {
  const { category, productId } = useParams<ProductParams>();
  
  // Beide Parameter extrahieren und validieren
  if (!category || !productId) {
    return <Navigate to="/products" replace />;
  }
  
  // Jetzt sicher verwendbar
  return (
    <div>
      <h1>{category}</h1>
      <ProductInfo id={productId} />
    </div>
  );
}

31.3.2 Query-Parameter: Optionale Konfiguration

Query-Parameter verfeinern den Seitenzustand, ohne die grundlegende Identität zu ändern. Sie folgen dem Fragezeichen: ?page=2&search=react&sortBy=date.

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

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();
  
  const page = parseInt(searchParams.get('page') || '1', 10);
  const search = searchParams.get('search') || '';
  const sortBy = searchParams.get('sortBy') || 'name';
  
  const updatePage = (newPage: number) => {
    setSearchParams(prev => {
      prev.set('page', String(newPage));
      return prev;
    });
  };
  
  const updateSearch = (term: string) => {
    setSearchParams(prev => {
      if (term) {
        prev.set('search', term);
        prev.set('page', '1');  // Reset bei neuer Suche
      } else {
        prev.delete('search');
      }
      return prev;
    });
  };
  
  return (
    <div>
      <input 
        value={search} 
        onChange={(e) => updateSearch(e.target.value)}
        placeholder="Suche..."
      />
      <Pagination current={page} onChange={updatePage} />
      <Products filters={{ search, sortBy, page }} />
    </div>
  );
}

Query-Parameter-Updates sollten bei Suchfeldern debounced werden. Direktes Update bei jeder Tasteneingabe erzeugt Performance-Probleme.

function SearchBox() {
  const [searchParams, setSearchParams] = useSearchParams();
  const [localSearch, setLocalSearch] = useState(
    searchParams.get('search') || ''
  );
  
  useEffect(() => {
    const timeoutId = setTimeout(() => {
      setSearchParams(prev => {
        if (localSearch) {
          prev.set('search', localSearch);
        } else {
          prev.delete('search');
        }
        return prev;
      });
    }, 300);
    
    return () => clearTimeout(timeoutId);
  }, [localSearch, setSearchParams]);
  
  return (
    <input 
      value={localSearch}
      onChange={(e) => setLocalSearch(e.target.value)}
    />
  );
}

React Router bietet sowohl deklarative als auch imperative Navigation. Die Wahl hängt vom Kontext ab: Deklarativ für Komponenten-Struktur, imperativ für Event-Handler und asynchrone Logik.

Die Navigate-Komponente triggert eine Umleitung während des Renderings. Sie ersetzt das veraltete Redirect aus v5.

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

function Dashboard() {
  const { user } = useAuth();
  
  if (!user) {
    return <Navigate to="/login" replace />;
  }
  
  return <div>Welcome, {user.name}</div>;
}

Der replace-Parameter ist kritisch. Mit replace={true} wird der History-Eintrag ersetzt statt einen neuen hinzuzufügen. Bei Authentifizierungs-Flows verhindert das, dass Nutzer über den Zurück-Button zur Login-Seite zurückkehren, nachdem sie bereits eingeloggt sind.

<Navigate to="/dashboard" replace />  // Ersetzt aktuellen History-Eintrag
<Navigate to="/dashboard" />         // Fügt neuen History-Eintrag hinzu

State kann zwischen Routen übertragen werden:

function ProtectedRoute({ children }: { children: ReactNode }) {
  const { isAuthenticated } = useAuth();
  const location = useLocation();
  
  if (!isAuthenticated) {
    return (
      <Navigate 
        to="/login" 
        state={{ from: location.pathname }}
        replace 
      />
    );
  }
  
  return <>{children}</>;
}

function LoginPage() {
  const location = useLocation();
  const navigate = useNavigate();
  const from = location.state?.from || '/';
  
  const handleLogin = async (credentials: Credentials) => {
    await authenticate(credentials);
    navigate(from, { replace: true });  // Zurück zur ursprünglichen Route
  };
  
  return <LoginForm onSubmit={handleLogin} />;
}

31.4.2 useNavigate: Imperative Navigation

Der useNavigate-Hook liefert eine Funktion für programmatische Navigation – ideal für Event-Handler, nach API-Calls oder in komplexer Geschäftslogik.

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

function ProductForm() {
  const navigate = useNavigate();
  
  const handleSubmit = async (data: ProductData) => {
    try {
      const product = await createProduct(data);
      navigate(`/products/${product.id}`, { replace: true });
    } catch (error) {
      // Fehlerbehandlung
    }
  };
  
  return <form onSubmit={handleSubmit}>...</form>;
}

Relative Navigation ist ebenfalls möglich:

navigate(-1);   // Zurück
navigate(-2);   // Zwei Schritte zurück
navigate(1);    // Vorwärts
navigate('/products');  // Absolute Navigation
navigate('../');        // Relative Navigation

Options-Objekt bietet feinere Kontrolle:

navigate('/dashboard', {
  replace: true,                    // History-Eintrag ersetzen
  state: { fromCheckout: true }    // State mitgeben
});

31.5 Protected Routes: Authentifizierung und Autorisierung

Private Routen sind die erste Verteidigungslinie gegen unbefugten Zugriff. React Router selbst implementiert keine Authentifizierung – das Pattern kombiniert Routing mit React-Konzepten wie Context und bedingtem Rendering.

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  
  useEffect(() => {
    // Token aus localStorage lesen und validieren
    const token = localStorage.getItem('auth_token');
    if (token) {
      validateToken(token)
        .then(setUser)
        .catch(() => localStorage.removeItem('auth_token'))
        .finally(() => setIsLoading(false));
    } else {
      setIsLoading(false);
    }
  }, []);
  
  const login = async (credentials: Credentials) => {
    const { user, token } = await authenticate(credentials);
    localStorage.setItem('auth_token', token);
    setUser(user);
  };
  
  const logout = () => {
    localStorage.removeItem('auth_token');
    setUser(null);
  };
  
  if (isLoading) {
    return <LoadingSpinner />;
  }
  
  return (
    <AuthContext.Provider value={{ 
      user, 
      isAuthenticated: !!user, 
      login, 
      logout 
    }}>
      {children}
    </AuthContext.Provider>
  );
}

function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

Protected Route-Wrapper prüft den Auth-Status und leitet um:

interface ProtectedRouteProps {
  children: ReactNode;
  requiredRole?: string;
}

function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
  const { user, isAuthenticated } = useAuth();
  const location = useLocation();
  
  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  
  if (requiredRole && user?.role !== requiredRole) {
    return <Navigate to="/unauthorized" replace />;
  }
  
  return <>{children}</>;
}

// Verwendung in Routes
<Routes>
  <Route path="/login" element={<LoginPage />} />
  
  <Route path="/dashboard" element={
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  } />
  
  <Route path="/admin" element={
    <ProtectedRoute requiredRole="admin">
      <AdminPanel />
    </ProtectedRoute>
  } />
</Routes>

Für granulare Berechtigungen erweitert sich das Pattern:

interface Permission {
  resource: string;
  action: string;
}

function hasPermission(user: User, resource: string, action: string): boolean {
  return user.permissions.some(
    p => p.resource === resource && p.action === action
  );
}

interface PermissionGuardProps {
  children: ReactNode;
  resource: string;
  action: string;
  fallback?: ReactNode;
}

function PermissionGuard({ 
  children, 
  resource, 
  action, 
  fallback = null 
}: PermissionGuardProps) {
  const { user } = useAuth();
  
  if (!user || !hasPermission(user, resource, action)) {
    return <>{fallback}</>;
  }
  
  return <>{children}</>;
}

// Verwendung
<PermissionGuard resource="products" action="delete">
  <DeleteProductButton />
</PermissionGuard>

31.6 Server-Konfiguration und Deployment

Die Router-Wahl beeinflusst das Deployment fundamental. BrowserRouter benötigt Server-Config, HashRouter funktioniert überall.

Für Cloud-Anbieter gibt es spezifische Patterns:

Vercel/Netlify: _redirects oder vercel.json konfigurieren SPA-Fallback:

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

AWS S3 + CloudFront: Error-Pages auf index.html umleiten mit 200-Status statt 404.

Docker/Node Server: Express-Fallback-Route:

app.use(express.static('build'));

app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'build', 'index.html'));
});

31.7 Performance-Überlegungen

React Router ist für Performance optimiert, aber einige Patterns können Probleme verursachen.

Lazy Loading von Route-Komponenten reduziert Initial Bundle Size:

import { lazy, Suspense } from 'react';

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

<Routes>
  <Route path="/dashboard" element={
    <Suspense fallback={<LoadingSpinner />}>
      <Dashboard />
    </Suspense>
  } />
  <Route path="/products" element={
    <Suspense fallback={<LoadingSpinner />}>
      <Products />
    </Suspense>
  } />
</Routes>

Parameter-abhängige Daten sollten mit Libraries wie React Query geladen werden:

import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';

function ProductDetail() {
  const { productId } = useParams<{ productId: string }>();
  
  const { data, isLoading, error } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId!),
    enabled: !!productId
  });
  
  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!data) return null;
  
  return <Product data={data} />;
}

React Query cached automatisch, dedupliziert Requests und managed Loading-States – alles Parameter-sensitiv.

31.8 Häufige Fallstricke

Normale <a>-Tags statt <Link> brechen das SPA-Paradigma. Jeder Klick triggert eine vollständige Seitenladung.

// ❌ Falsch: Full Page Reload
<a href="/products">Products</a>

// ✓ Richtig: Client-Side Navigation
<Link to="/products">Products</Link>

Router außerhalb der Komponenten-Hierarchie führt zu Fehlern:

// ❌ Falsch
function App() {
  return (
    <div>
      <Navigation />  {/* Versucht useNavigate() */}
      <BrowserRouter>
        <Routes>...</Routes>
      </BrowserRouter>
    </div>
  );
}

// ✓ Richtig
function App() {
  return (
    <BrowserRouter>
      <Navigation />
      <Routes>...</Routes>
    </BrowserRouter>
  );
}

Fehlende 404-Route zeigt leere Seiten bei unbekannten URLs:

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
  {/* Immer eine Catch-All-Route */}
  <Route path="*" element={<NotFound />} />
</Routes>

Race Conditions bei asynchronen Navigate-Calls:

function Form() {
  const navigate = useNavigate();
  const [isMounted, setIsMounted] = useState(true);
  
  useEffect(() => {
    return () => setIsMounted(false);
  }, []);
  
  const handleSubmit = async (data: FormData) => {
    const result = await saveData(data);
    
    // Nur navigieren wenn Komponente noch existiert
    if (isMounted) {
      navigate('/success');
    }
  };
  
  return <form onSubmit={handleSubmit}>...</form>;
}

React Router verbindet URLs mit React-Komponenten, ohne das SPA-Paradigma zu kompromittieren. Die richtige Wahl von Router-Typ, Parameter-Strategie und Auth-Pattern macht den Unterschied zwischen einer funktionierenden und einer professionellen Anwendung.