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).
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:
- Er kapselt den Listener-Lifecycle (Mount, Cleanup) in
useEffect. - Er macht das ganze SSR-sicher — auf dem Server existiert
window.matchMedianicht.
Der Hook in voller Form
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:
-
SSR-sicher mit
typeof window-Test. Auf dem Server gibt es keinwindow. Der Hook muss das erkennen und einen sinnvollen Default liefern.falseist hier konservativ — bedeutet „die Query trifft (vorerst) nicht zu". Beim Hydration im Browser wird der echte Wert nachgezogen. -
Lazy Initial via Funktions-Form von
useState.useState(getMatch)ruftgetMatchnur beim Mount auf, nicht bei jedem Render. Wichtig für Performance. -
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. -
Change-Listener registrieren. Wenn der User das Fenster resized, das Gerät rotiert oder eine andere relevante Änderung passiert, feuert das
change-Event. -
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
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
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
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:
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:
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:
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:
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
- Window: matchMedia() – MDN
- MediaQueryList – MDN
- @media – MDN CSS Reference
- prefers-reduced-motion – MDN
- prefers-color-scheme – MDN