External Store sauber subscriben
Externe Datenquellen in React zu integrieren, war vor React 18 ein Minenfeld aus Race Conditions und inkonsistenten Renders. useSyncExternalStore ist die offizielle Lösung: ein Hook, der externe Stores korrekt mit dem React-Rendering-Zyklus verbindet und Tearing auch im Concurrent Mode verhindert.
Inhaltsverzeichnis
- 1. Was externe Stores sind und warum sie besonders behandelt werden müssen
- 2. Das Tearing-Problem im Concurrent Mode
- 3. Die API von useSyncExternalStore
- 4. Eigenen minimalen Store bauen
- 5. Browser-APIs als externe Stores abonnieren
- 6. Server-Side Rendering und getServerSnapshot
- 7. Selektoren und Performance-Optimierung
- 8. useSyncExternalStore vs. useEffect + useState
- 9. Integration mit bestehenden Store-Bibliotheken
- 10. Zusammenfassung
- 11. FAQ
1. Was externe Stores sind und warum sie besonders behandelt werden müssen
Ein externer Store ist eine Datenquelle, die außerhalb des React-State-Systems lebt: ein Redux-Store, ein Zustand-Store, ein selbst geschriebener Event-Emitter, eine Browser-API wie window.matchMedia oder navigator.onLine, oder ein WebSocket-Feed. Diese Quellen haben gemeinsam, dass sie ihren State selbst verwalten und React über Änderungen benachrichtigen müssen – anstatt dass React den State direkt kontrolliert.
Das Problem: React rendert Komponenten in mehreren Phasen. Im Concurrent Mode kann React ein Render starten, pausieren und fortsetzen. Wenn ein externer Store zwischen zwei Render-Phasen seinen State ändert, können verschiedene Teile des Komponentenbaums unterschiedliche State-Schnappschüsse sehen. Das Resultat ist Tearing: Die UI zeigt inkonsistente Daten – ein Teil der Seite zeigt den alten State, ein anderer den neuen. useSyncExternalStore ist die einzige korrekte Lösung für dieses Problem in React 18+.
2. Das Tearing-Problem im Concurrent Mode
Tearing klingt theoretisch, ist aber in der Praxis ein reales Problem für jede Anwendung, die externe State-Quellen mit useEffect + useState integriert. Das klassische Muster – im useEffect subscriben, im Subscriber setState aufrufen – funktioniert im Legacy-Synchronous-Mode korrekt, weil React dort alle Renders synchron und unterbrechungsfrei durchführt. Im Concurrent Mode hingegen kann React eine Render-Traversal unterbrechen, um auf wichtigere Aufgaben zu reagieren.
Wenn während dieser Unterbrechung der externe Store seinen Wert ändert, beginnt React den Render von einer bestimmten Stelle neu. Komponenten, die bereits gerendert wurden, haben den alten Wert gesehen. Neu gerenderte Komponenten lesen den neuen Wert. Das Ergebnis ist eine inkonsistente UI in einem einzigen Commit. useSyncExternalStore verhindert das, indem React nach jedem Render-Durchgang prüft, ob der Snapshot des externen Stores noch mit dem übereinstimmt, den die Komponenten während des Renders gelesen haben. Bei Abweichung erzwingt React einen synchronen Re-Render des gesamten betroffenen Teilbaums.
// Correct: useSyncExternalStore prevents tearing
import { useSyncExternalStore } from 'react';
// Minimal external store implementation
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
// React calls this to subscribe — must return unsubscribe function
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
// React calls this synchronously during render — must be stable
getSnapshot() {
return state;
},
// Dispatch triggers all subscribers
setState(newState) {
state = typeof newState === 'function' ? newState(state) : newState;
listeners.forEach(l => l());
},
};
}
const counterStore = createStore({ count: 0 });
function Counter() {
// React subscribes, reads snapshot, and ensures consistency
const { count } = useSyncExternalStore(
counterStore.subscribe,
counterStore.getSnapshot
);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => counterStore.setState(s => ({ count: s.count + 1 }))}>
+1
</button>
</div>
);
}
3. Die API von useSyncExternalStore
useSyncExternalStore nimmt drei Parameter entgegen, von denen zwei Pflicht und einer optional sind. Der erste Parameter ist die subscribe-Funktion: Sie erhält einen Listener-Callback und muss eine Funktion zurückgeben, die das Abonnement beendet. React ruft subscribe beim Mount auf und die zurückgegebene Cleanup-Funktion beim Unmount – exakt wie useEffect. Der zweite Parameter ist getSnapshot: Eine synchrone, seiteneffektfreie Funktion, die den aktuellen Wert des Stores zurückgibt. React ruft sie bei jedem Render auf.
Ein kritisches Detail: getSnapshot muss referenziell stabile Werte zurückgeben, wenn sich der Store-State nicht geändert hat. Wenn getSnapshot bei jedem Aufruf ein neues Objekt erzeugt – selbst mit denselben Werten – behandelt React das als State-Änderung und rendert neu. Das ist die häufigste Performance-Falle bei useSyncExternalStore: Snapshots, die immer neue Objekte zurückgeben, erzeugen Endlos-Re-Render. Der dritte optionale Parameter ist getServerSnapshot – er wird im nächsten Abschnitt behandelt.
4. Eigenen minimalen Store bauen
Mit useSyncExternalStore lässt sich ein vollständiger State-Container in wenigen Zeilen bauen, ohne externe Bibliotheken. Das Grundmuster besteht aus drei Teilen: State-Speicher (eine Variable außerhalb von React), Subscriber-Set (eine Set-Instanz für Listener) und Store-API (subscribe, getSnapshot, dispatch/setState). Dieser Store verhält sich korrekt im Concurrent Mode, unterstützt beliebige State-Typen und ermöglicht überall in der Anwendung denselben geteilten State – ohne Prop-Drilling und ohne Context-Performance-Probleme.
Der entscheidende Unterschied zu einem naiven useState-Ansatz: Der Store lebt komplett außerhalb des Komponentenbaums. State-Änderungen von außen – aus einem WebSocket-Handler, einem Timer oder einem anderen Nicht-React-Kontext – funktionieren korrekt, weil der Store seine Subscriber selbst benachrichtigt. React reagiert auf diese Benachrichtigungen durch useSyncExternalStore korrekt und ohne Tearing.
// Generic typed store factory with useSyncExternalStore
import { useSyncExternalStore, useCallback } from 'react';
function createTypedStore<T>(initial: T) {
let state = initial;
const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const getSnapshot = () => state;
const setState = (updater: T | ((prev: T) => T)) => {
state = typeof updater === 'function'
? (updater as (prev: T) => T)(state)
: updater;
listeners.forEach(l => l());
};
return { subscribe, getSnapshot, setState };
}
// Create stores as module-level singletons
const userStore = createTypedStore({ name: '', role: 'guest' as const });
const themeStore = createTypedStore<'light' | 'dark'>('light');
// Custom hook for consuming a store with optional selector
function useStore<T, S>(store: ReturnType<typeof createTypedStore<T>>, selector: (s: T) => S): S {
const getSlice = useCallback(() => selector(store.getSnapshot()), [store, selector]);
return useSyncExternalStore(store.subscribe, getSlice);
}
// Usage in components
function UserBadge() {
const name = useStore(userStore, s => s.name);
return <span>{name || 'Gast'}</span>;
}
function ThemeToggle() {
const theme = useSyncExternalStore(themeStore.subscribe, themeStore.getSnapshot);
return (
<button onClick={() => themeStore.setState(t => t === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
</button>
);
}
5. Browser-APIs als externe Stores abonnieren
Eine oft übersehene Stärke von useSyncExternalStore ist die saubere Integration von Browser-APIs, die selbst über ein Subscribe-/Unsubscribe-Muster verfügen: window.matchMedia für Media-Query-Ergebnisse, navigator.onLine für den Online-Status, das visibilitychange-Event für Tab-Sichtbarkeit oder der resize-Event für Fenstergrößen. Diese APIs liefern State, der außerhalb von React mutiert und in React synchronisiert werden muss – exakt der Use Case von useSyncExternalStore.
Der Vorteil gegenüber dem klassischen useEffect-Pattern: Kein Race Condition zwischen dem Lesen des initialen Werts beim Mount und dem Subscriben auf Änderungen. Mit useSyncExternalStore liest React den Snapshot beim Render synchron und subscribt gleichzeitig – zwischen diesen beiden Operationen kann kein Wert verloren gehen. Für SSR-kompatible Browser-API-Hooks ist außerdem getServerSnapshot wichtig, das einen sicheren Fallback-Wert für den Server liefert, wo keine Browser-APIs verfügbar sind.
6. Server-Side Rendering und getServerSnapshot
Der dritte Parameter von useSyncExternalStore, getServerSnapshot, löst ein spezifisches SSR-Problem. Auf dem Server existieren keine Browser-APIs, kein localStorage und kein globaler Window-State. Wenn getSnapshot auf diese Quellen zugreift, wirft es auf dem Server Fehler. getServerSnapshot ist die Funktion, die React während des Server-Renders aufruft, statt getSnapshot. Sie muss einen stabilen, deterministischen Wert zurückgeben, der auf dem Server und beim ersten Client-Render identisch ist – sonst entstehen Hydration-Mismatches.
Ein wichtiges Constraint: Der Wert, den getServerSnapshot zurückgibt, muss beim Server-Render und beim Client-Hydration-Render identisch sein. Das bedeutet: Browser-spezifische Werte wie die aktuelle Fenstergröße oder der Online-Status dürfen in getServerSnapshot nicht abgerufen werden. Stattdessen gibt man einen Safe-Default-Wert zurück – etwa true für Online-Status oder eine Standard-Viewport-Größe. Erst nach der Hydration übernimmt getSnapshot und liefert den echten Browser-Wert.
// Browser API as external store — online status with SSR support
import { useSyncExternalStore } from 'react';
function subscribeToOnline(callback: () => void) {
// Subscribe to browser events
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getOnlineSnapshot(): boolean {
return navigator.onLine; // browser-only — not called on server
}
function getServerOnlineSnapshot(): boolean {
return true; // safe default for SSR — avoids hydration mismatch
}
function useOnlineStatus(): boolean {
return useSyncExternalStore(
subscribeToOnline,
getOnlineSnapshot,
getServerOnlineSnapshot // third argument: SSR snapshot
);
}
// Media query hook — reactive, SSR-safe
function useMediaQuery(query: string): boolean {
const subscribe = (cb: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', cb);
return () => mql.removeEventListener('change', cb);
};
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false // server fallback
);
}
// Usage
function NetworkBadge() {
const isOnline = useOnlineStatus();
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<span style={ { color: isOnline ? 'green' : 'red' } }>
{isOnline ? 'Online' : 'Offline'} {isMobile && '(Mobile)'}
</span>
);
}
7. Selektoren und Performance-Optimierung
Bei großen externen Stores rendert jede Store-Änderung alle Komponenten neu, die den Store via useSyncExternalStore abonniert haben – unabhängig davon, ob sich der Teil des Stores, den eine bestimmte Komponente tatsächlich liest, geändert hat. Die Lösung sind Selektoren: getSnapshot gibt nicht den gesamten Store zurück, sondern nur den relevanten Slice. Wenn dieser Slice unverändert ist, erkennt React das an der referenziellen Gleichheit und überspringt den Re-Render.
Das Problem: Wenn getSnapshot ein neues Objekt per Object.assign oder Spread-Operator zurückgibt, ist es selbst bei gleichen Werten referenziell verschieden – und React rendert neu. Die Lösung ist Memoization des Snapshots: Mit einem einfachen Cache-Vergleich im Selector prüft man, ob sich die Quellwerte geändert haben, und gibt denselben Objekt-Referenz zurück, wenn nicht. Bibliotheken wie Zustand und Redux Toolkit lösen das intern für ihre useSyncExternalStore-Integrationen – bei selbst geschriebenen Stores muss man es selbst implementieren.
8. useSyncExternalStore vs. useEffect + useState
Das klassische Pattern für externe Store-Integration war useEffect + useState: Im Effect subscriben, im Subscriber setState aufrufen, im Cleanup unsubscriben. Das funktioniert für einfache Fälle im Synchronous-Render-Mode, hat aber drei strukturelle Probleme: Erstens gibt es ein kurzes Zeitfenster zwischen dem ersten Render und dem Effect-Start, in dem der Store-State bereits geändert haben kann – der erste Render zeigt dann einen veralteten Wert. Zweitens tritt im Concurrent Mode das Tearing-Problem auf. Drittens kann der Cleanup der alten Subscription und die neue Subscription kurz auseinanderfallen, wenn Props oder Params sich ändern.
useSyncExternalStore löst alle drei Probleme strukturell: Es gibt kein Zeitfenster zwischen Lesen und Subscriben, weil beides synchron im Rendering-Pfad passiert. Tearing wird durch die Post-Render-Snapshot-Prüfung verhindert. Und Subscription-Wechsel bei geänderten Parametern werden korrekt gehandhabt, weil React subscribe mit den aktuellen Werten neu aufruft. Der einzige Vorteil von useEffect + useState ist die Flexibilität für komplexe asynchrone Initializations – was aber auf Kosten der Korrektheit geht.
| Eigenschaft | useEffect + useState | useSyncExternalStore |
|---|---|---|
| Tearing-Sicherheit | Nicht gegeben im Concurrent Mode | Garantiert |
| Initial-State-Race | Möglich (Effect-Delay) | Nicht möglich |
| SSR-Unterstützung | Manuell via suppressHydrationWarning | Nativ via getServerSnapshot |
| Boilerplate | Hoch | Gering |
| React-Integration | Extern (Effect-Phase) | Nativ (Render-Phase) |
9. Integration mit bestehenden Store-Bibliotheken
Alle modernen React-State-Libraries haben ihre Internals auf useSyncExternalStore umgebaut. Zustand (ab v4) nutzt es als Kern seiner React-Integration. Redux Toolkit verwendet es in react-redux ab v8. Jotai und Valtio bauen intern auf demselben Prinzip auf. Wer eine dieser Libraries nutzt, profitiert bereits von der korrekten Concurrent-Mode-Semantik, ohne selbst useSyncExternalStore aufzurufen.
Relevant wird useSyncExternalStore direkt für Library-Autoren und für Teams, die proprietäre externe State-Quellen integrieren müssen: Websocket-Datenfeeds, Browser-Extension-Kommunikation, SharedWorker-State oder Legacy-Datenquellen, die nicht über React-Context laufen. In diesen Fällen ist useSyncExternalStore die einzige korrekte Brücke zwischen dem externen System und Reacts Rendering-Engine. Der Einstiegspunkt ist bewusst minimal: Wer drei einfache Funktionen liefern kann (subscribe, getSnapshot, optional getServerSnapshot), hat einen vollständig React-konformen externen Store.
// WebSocket feed as external store — real-time data without tearing
type PriceData = { symbol: string; price: number; updatedAt: number };
function createPriceFeedStore(wsUrl: string) {
let latestData: PriceData | null = null;
const listeners = new Set<() => void>();
let socket: WebSocket | null = null;
function connect() {
socket = new WebSocket(wsUrl);
socket.onmessage = (event) => {
latestData = JSON.parse(event.data) as PriceData;
listeners.forEach(l => l()); // notify React
};
}
connect(); // connect immediately as module-level singleton
return {
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot(): PriceData | null {
return latestData; // stable reference if unchanged
},
getServerSnapshot(): PriceData | null {
return null; // no WebSocket on server
},
};
}
const priceFeed = createPriceFeedStore('wss://api.example.com/prices');
function LivePriceTicker() {
const data = useSyncExternalStore(
priceFeed.subscribe,
priceFeed.getSnapshot,
priceFeed.getServerSnapshot
);
if (!data) return <span>Verbinden...</span>;
return (
<span>
{data.symbol}: {data.price.toFixed(2)} €
</span>
);
}
10. Zusammenfassung
useSyncExternalStore ist der React-Hook für alle Fälle, in denen State außerhalb von React lebt und in den Komponentenbaum synchronisiert werden muss. Die drei Kernprinzipien: Erstens muss getSnapshot rein und synchron sein und referenziell stabile Werte zurückgeben. Zweitens muss subscribe eine Cleanup-Funktion zurückgeben. Drittens muss getServerSnapshot für SSR-kompatible Anwendungen einen sicheren, deterministischen Server-Default liefern.
Das wichtigste Take-away: Das klassische useEffect + useState-Pattern für externe Stores ist im Concurrent Mode nicht korrekt – nicht als theoretisches Problem, sondern als reales Tearing-Risiko in jeder Anwendung, die React 18+ und Concurrent Features nutzt. useSyncExternalStore ist die einzige korrekte Lösung, ist seit React 18 stabil und erfordert keine externe Bibliothek. Für Browser-API-Hooks, WebSocket-Feeds, eigene State-Container und Library-Entwicklung ist es der unverzichtbare Baustein.
useSyncExternalStore — Das Wichtigste auf einen Blick
Tearing-Prävention
React prüft nach jedem Render, ob der Snapshot noch aktuell ist. Bei Abweichung: synchroner Re-Render. Kein Tearing im Concurrent Mode.
Stabile Snapshots
getSnapshot muss bei unverändertem State dieselbe Referenz zurückgeben. Neue Objekte bei jedem Aufruf erzeugen Endlos-Re-Render.
SSR-Kompatibilität
getServerSnapshot liefert den Server-Default. Muss beim Server-Render und Client-Hydration identisch sein, um Hydration-Mismatches zu verhindern.
Einsatzbereich
Browser-APIs, WebSocket-Feeds, eigene Store-Container, Library-Internals – überall dort, wo State außerhalb von React lebt.