Responsive Design ist in CSS gelöst — Media Queries entscheiden, welche Styles bei welcher Viewport-Größe greifen. Aber manchmal reicht CSS nicht: eine Komponente soll strukturell anders rendern je nach Viewport (z.B. ein Burger-Menü auf Mobile, ein voll ausgebreiteter Header auf Desktop), oder ein JavaScript-Verhalten soll bei bestimmter Bildschirmgröße abgeschaltet werden. Dafür gibt es window.matchMedia — eine JavaScript-API, mit der man Media Queries aus dem Code heraus abfragen und auf Änderungen reagieren kann. Der useMediaQuery-Custom-Hook bündelt das idiomatisch: man gibt eine Media-Query-Zeichenkette an und bekommt einen Boolean zurück, der bei Viewport-Wechseln automatisch aktualisiert.

Wann CSS reicht, wann JavaScript nötig ist

Zuerst die Grundregel: wenn das Problem mit CSS lösbar ist, mach es mit CSS. JavaScript-basierte Responsiveness hat zwei harte Nachteile gegenüber CSS-Media-Queries:

  • JavaScript läuft nach dem ersten Render. Beim Server-Side-Rendering ist die Viewport-Größe unbekannt — JS kann erst im Browser entscheiden, was zu kurzem Layout-Flicker führt.
  • JavaScript ist teurer als CSS. Browser haben Media-Query-Engine in hochoptimiertem nativem Code — JS-basierte Anpassungen müssen durch den React-Render-Zyklus.

JavaScript ist nötig, wenn:

  • die Komponente strukturell unterschiedlich rendern soll — z.B. unterschiedliche Komponenten-Bäume auf Mobile und Desktop, nicht nur unterschiedliche Styles.
  • ein Verhalten an die Viewport-Größe gebunden ist — z.B. ein Slider-Plugin nur auf Desktop initialisieren.
  • Bedingte Daten geladen werden — z.B. kleinere Bilder auf Mobile vorab requesten.

Für reines „auf Mobile blau, auf Desktop rot" reicht eine CSS-Media-Query — kein JS-Hook nötig.

window.matchMedia — die Browser-API

window.matchMedia(query) liefert ein MediaQueryList-Objekt mit zwei wesentlichen Properties:

  • .matches — ein Boolean: trifft die Query gerade zu?
  • .addEventListener('change', handler) — feuert, wenn sich der Match-Status ändert (z.B. weil der User das Browser-Fenster verkleinert).
JavaScript matchMedia-Roh.js
const mql = window.matchMedia('(min-width: 768px)');

console.log(mql.matches);  // true oder false, abhängig vom aktuellen Viewport

mql.addEventListener('change', (event) => {
    console.log('Match-Status hat gewechselt:', event.matches);
});

Das ist die Grundlage, auf der useMediaQuery aufbaut. Der Hook bringt zwei Verbesserungen:

  1. Er kapselt den Listener-Lifecycle (Mount, Cleanup) in useEffect.
  2. Er macht das ganze SSR-sicher — auf dem Server existiert window.matchMedia nicht.

Der Hook in voller Form

TypeScript useMediaQuery.js
import { useState, useEffect } from 'react';

export function useMediaQuery(query) {
    // 1. SSR-sicher: auf dem Server liefert getMatch() den safe Default false
    const getMatch = () => {
        if (typeof window === 'undefined' || !window.matchMedia) {
            return false;
        }
        return window.matchMedia(query).matches;
    };

    // 2. Lazy Initial — getMatch läuft nur beim Mount
    const [matches, setMatches] = useState(getMatch);

    useEffect(() => {
        if (typeof window === 'undefined' || !window.matchMedia) return;

        const mql = window.matchMedia(query);

        // 3. Falls der Wert zwischen SSR-Render und Hydration gewechselt hat
        if (mql.matches !== matches) {
            setMatches(mql.matches);
        }

        // 4. Listener für künftige Änderungen
        const handler = (event) => setMatches(event.matches);
        mql.addEventListener('change', handler);

        // 5. Cleanup bei Unmount oder Query-Wechsel
        return () => mql.removeEventListener('change', handler);
    }, [query]);

    return matches;
}

Die fünf nummerierten Stellen:

  1. SSR-sicher mit typeof window-Test. Auf dem Server gibt es kein window. Der Hook muss das erkennen und einen sinnvollen Default liefern. false ist hier konservativ — bedeutet „die Query trifft (vorerst) nicht zu". Beim Hydration im Browser wird der echte Wert nachgezogen.

  2. Lazy Initial via Funktions-Form von useState. useState(getMatch) ruft getMatch nur beim Mount auf, nicht bei jedem Render. Wichtig für Performance.

  3. Hydration-Korrektur. Zwischen dem SSR-Render (Server liefert false) und der Client-Hydration kann sich der Wert geändert haben — der echte Browser-Wert liegt vor. Wir setzen den State neu, falls er abweicht. Verhindert kurzes Layout-Flicker.

  4. Change-Listener registrieren. Wenn der User das Fenster resized, das Gerät rotiert oder eine andere relevante Änderung passiert, feuert das change-Event.

  5. Cleanup mit removeEventListener. Pflicht — sonst Memory-Leak und Listener bleiben aktiv, auch nachdem die Komponente unmounted ist.

Konsumieren — drei klassische Use-Cases

Use-Case 1: Conditional Component-Bäume

TypeScript ResponsiveNavigation.jsx
import { useMediaQuery } from './useMediaQuery';

export default function Navigation() {
    const isDesktop = useMediaQuery('(min-width: 768px)');

    // Strukturell andere Komponente, nicht nur andere Styles
    return isDesktop ? <DesktopNav /> : <MobileNav />;
}

Sinnvoll, wenn die mobile und Desktop-Version sich architektonisch unterscheiden — z.B. Mobile hat ein Drawer-Pattern, Desktop hat eine ausgeklappte Seitenleiste. Pure CSS könnte das nicht trennen.

Use-Case 2: Prefers-Reduced-Motion

TypeScript AnimationGate.jsx
function HeroSection() {
    const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');

    return (
        <section>
            <h1>Willkommen</h1>
            {!prefersReducedMotion && <FancyAnimatedBackground />}
        </section>
    );
}

User, die im Betriebssystem „reduzierte Bewegung" aktiviert haben (Accessibility-Setting), bekommen die teure animierte Hintergrund-Komponente gar nicht. Wichtige a11y-Praxis.

Use-Case 3: Dark-Mode-Detection

TypeScript SystemTheme.jsx
function App() {
    const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');

    useEffect(() => {
        document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light';
    }, [prefersDark]);

    // …
}

Reagiert auf das Betriebssystem-Theme-Setting des Users — und passt sich dynamisch an, wenn der User es im laufenden Betrieb umstellt.

Breakpoint-Konstanten — saubere Wiederverwendung

Statt überall '(min-width: 768px)' als Magic-String zu wiederholen, lohnt sich ein Konstanten-Modul:

TypeScript breakpoints.js
export const breakpoints = {
    sm: '(min-width: 640px)',
    md: '(min-width: 768px)',
    lg: '(min-width: 1024px)',
    xl: '(min-width: 1280px)',
};

// Oder als pre-built Hooks:
export const useIsMobile = () => !useMediaQuery(breakpoints.md);
export const useIsTablet = () => useMediaQuery(breakpoints.md) && !useMediaQuery(breakpoints.lg);
export const useIsDesktop = () => useMediaQuery(breakpoints.lg);

Wichtig: die Breakpoint-Werte sollten mit den CSS-Breakpoints identisch sein — sonst hat man zwei Quellen der Wahrheit, die auseinanderdriften können. Idealerweise: ein Modul, das sowohl im CSS (via CSS-Custom-Properties oder SCSS-Variablen) als auch im JS verwendet wird.

SSR und der Hydration-Mismatch

Bei SSR (Next.js, Astro, Remix) gibt es ein subtiles Problem: der Server rendert mit matches: false (Default), der Client hydrated mit dem echten Wert. Wenn die Komponente dadurch unterschiedliches JSX rendert, gibt es Hydration-Mismatch-Warning:

TypeScript HydrationMismatch.jsx
function Page() {
    const isDesktop = useMediaQuery('(min-width: 768px)');
    // Server rendert <MobileNav />, Client hydrated mit <DesktopNav />
    // → React Hydration-Mismatch-Warning
    return isDesktop ? <DesktopNav /> : <MobileNav />;
}

Lösungen:

  • CSS-only. Wenn möglich, das Problem in CSS erledigen: beide Komponenten rendern, eine per CSS verstecken. Keine JS-Logik, kein Mismatch.
  • Mounted-State-Pattern. Erst nach dem Mount der Komponente die Media-Query-Logik aktivieren:
TypeScript MountedPattern.jsx
function Page() {
    const [mounted, setMounted] = useState(false);
    useEffect(() => setMounted(true), []);

    const isDesktop = useMediaQuery('(min-width: 768px)');

    if (!mounted) return <MobileNav />;  // Server + erster Client-Render
    return isDesktop ? <DesktopNav /> : <MobileNav />;
}

Trade-off: User mit Desktop-Viewport sehen kurz die Mobile-Variante, bevor sie auf Desktop wechselt. Daher: in Next.js & Co. wird CSS-only Responsive bevorzugt — Media-Query-Hooks nur für Aspekte, die NICHT mit CSS lösbar sind.

Browser-Kompatibilität: ältere addListener vs. moderner addEventListener

Frühere Browser unterstützten mql.addListener() und mql.removeListener() — die moderne addEventListener/removeEventListener-Form kam erst später dazu. Für volle Kompatibilität bis Safari 14:

TypeScript LegacyKompatibilitaet.js
useEffect(() => {
    if (typeof window === 'undefined' || !window.matchMedia) return;

    const mql = window.matchMedia(query);
    const handler = (event) => setMatches(event.matches);

    // Moderne API
    if (typeof mql.addEventListener === 'function') {
        mql.addEventListener('change', handler);
        return () => mql.removeEventListener('change', handler);
    }
    // Fallback für sehr alte Browser (Safari < 14)
    mql.addListener(handler);
    return () => mql.removeListener(handler);
}, [query]);

In modernen Projekten (Chrome 80+, Firefox 75+, Safari 14+) ist das Fallback nicht mehr nötig. Bei Browser-Support-Anforderungen bis Safari 13 oder älter: einbauen.

Interessantes

CSS-Media-Queries zuerst — JS nur, wenn CSS nicht reicht.

JavaScript-Responsiveness ist teurer und SSR-anfälliger. Pure-CSS-Lösungen sollten der Default sein. useMediaQuery ist für Cases, in denen die Komponenten-Struktur (nicht nur Styling) abhängt.

window.matchMedia ist die Browser-API, der Hook wickelt sie ein.

matchMedia(query) liefert ein MediaQueryList-Objekt mit .matches und change-Event. Der Hook kapselt Listener-Setup und SSR-Sicherheit.

SSR-Default false ist konservativ, aber sicher.

Auf dem Server kennen wir den Viewport nicht. false als Default bedeutet „kleinster Viewport" — meist die mobile Variante, die als Default-Fallback gut funktioniert.

Listener-Cleanup ist Pflicht — sonst Memory-Leak.

removeEventListener im useEffect-Cleanup-Return. Ohne das bleiben Listener nach Komponenten-Unmount aktiv und der GC kann den State nicht freigeben.

Hydration-Mismatch durch Mounted-State-Pattern lösen.

Bei SSR rendert Server false, Client hydrated mit echtem Wert. Wenn JSX davon abhängt: const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); bis dahin gleichen Wert wie Server rendern.

Breakpoint-Konstanten zentral pflegen — Single Source of Truth.

Magic-Strings wie '(min-width: 768px)' verteilen sich sonst über die Codebase. Ein breakpoints.js-Modul oder CSS-Custom-Properties geteilt nutzen.

prefers-reduced-motion und `prefers-color-scheme` sind a11y-Pflicht.

User-Präferenzen aus dem Betriebssystem respektieren: keine teuren Animationen für „reduce", Theme-Default nach System-Setting. Beides via useMediaQuery erreichbar.

addListener ist Legacy — moderne API ist `addEventListener('change', ...)`.

Beide gibt es; addEventListener ist konsistenter mit anderen DOM-APIs. Bei Browser-Support bis Safari 13 (oder älter) Fallback einbauen.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Custom Hooks

Zur Übersicht