useSyncExternalStore (React 18+) ist die offizielle React-API, um auf externe Datenquellen zu reagieren — Stores aus Libraries wie Redux/Zustand/Valtio, Browser-APIs wie localStorage/window.matchMedia, oder eigene Event-Emitter. Vor 18 nutzten Libraries useEffect + useState, was unter Concurrent-Rendering zu Tearing führen konnte (verschiedene Teile der UI zeigen unterschiedliche Versionen des Stores). useSyncExternalStore synchronisiert garantiert konsistent. Für App-Code direkt selten gebraucht — der Hook ist primär für Library-Authoren. Wer Redux nutzt: useSelector ist intern auf useSyncExternalStore aufgebaut.

Signatur

TypeScript signatur.ts
const snapshot = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot?
);
  • subscribe: Funktion, die einen Callback registriert. React ruft sie auf, übergibt einen notify-Callback. Wenn der Store sich ändert, Store ruft notify(). Rückgabe der subscribe-Funktion: Unsubscribe-Cleanup.
  • getSnapshot: liefert den aktuellen Wert aus dem Store. MUSS bei gleichen Daten dieselbe Referenz liefern.
  • getServerSnapshot (optional): wie getSnapshot, aber für SSR.

Beispiel — localStorage als Store

TypeScript useLocalStorage.js
import { useSyncExternalStore } from 'react';

function subscribe(callback) {
    window.addEventListener('storage', callback);
    return () => window.removeEventListener('storage', callback);
}

export function useLocalStorageValue(key) {
    return useSyncExternalStore(
        subscribe,
        () => localStorage.getItem(key),
        () => null   // beim SSR kein localStorage
    );
}

// Verwendung
function ThemeDisplay() {
    const theme = useLocalStorageValue('theme');
    return <p>Theme: {theme ?? 'default'}</p>;
}

Wenn localStorage.setItem('theme', 'dark') aus einer anderen Tab-Instanz aufgerufen wird, feuert das storage-Event und React rendert die Komponente mit dem neuen Wert.

Beispiel — Online-Status

TypeScript useOnline.js
import { useSyncExternalStore } from 'react';

function subscribeOnline(callback) {
    window.addEventListener('online', callback);
    window.addEventListener('offline', callback);
    return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
    };
}

export function useOnline() {
    return useSyncExternalStore(
        subscribeOnline,
        () => navigator.onLine,
        () => true   // SSR-Fallback
    );
}

function NetworkStatus() {
    const online = useOnline();
    return <p>Status: {online ? '🟢 online' : '🔴 offline'}</p>;
}

Beispiel — Eigener Event-Store

TypeScript customStore.js
// Store-Modul außerhalb von React
let counter = 0;
const listeners = new Set();

export const counterStore = {
    getSnapshot() { return counter; },
    subscribe(fn) {
        listeners.add(fn);
        return () => listeners.delete(fn);
    },
    increment() {
        counter++;
        listeners.forEach(fn => fn());
    },
};

// In React
function Counter() {
    const value = useSyncExternalStore(
        counterStore.subscribe,
        counterStore.getSnapshot
    );
    return (
        <>
            <p>Count: {value}</p>
            <button onClick={counterStore.increment}>+1</button>
        </>
    );
}

Der Store lebt außerhalb von React — beliebige Stellen können counterStore.increment() aufrufen, alle subscribten Komponenten rendern neu.

getSnapshot-Falle: gleiche Daten → gleiche Referenz

React vergleicht den Rückgabewert per Object.is. Wenn getSnapshot() bei unveränderten Daten ein neues Object liefert, denkt React „neu" und rendert unnötig — im Worst-Case Infinite-Loop.

TypeScript GetSnapshotFalle.jsx
// FALSCH: bei jedem Aufruf neues Object
const todos = useSyncExternalStore(
    store.subscribe,
    () => ({ todos: store.todos, count: store.todos.length })
    // → React sieht „neu" bei jedem Render → Infinite-Loop möglich
);

// KORREKT: Snapshot ist eine STABILE Referenz im Store
const todos = useSyncExternalStore(
    store.subscribe,
    () => store.todos   // gleicher Array, solange unverändert
);

Best Practice: getSnapshot ist eine PURE Funktion ohne neue Object-Allokation. Wenn Aggregation nötig: useMemo außen rum, oder die Aggregation im Store cachen.

Wann useSyncExternalStore?

Sinnvoll wenn:

  • Library-Autor für State-Management (Redux, Zustand, Valtio).
  • Integration mit Browser-APIs, die Events feuern (online/offline, mediaQuery, intersection).
  • Eigener globaler Event-Bus für App-übergreifenden State.

Nicht sinnvoll:

  • Normaler Komponenten-State → useState.
  • Server-State → TanStack Query, SWR.
  • Tief verschachtelte Komponenten-Kommunikation → Context.

Besonderheiten

useSyncExternalStore ist primär ein Library-Hook.

In App-Code direkt selten benutzt. Wenn du Redux oder Zustand nutzt: deren Hooks (useSelector, Zustand-Hook) sind intern auf useSyncExternalStore aufgebaut. Direkter Einsatz nur für Custom-Stores oder Browser-API-Wrapper.

subscribe ist die Anmelde-Funktion, NICHT der Listener selbst.

React übergibt einen callback an deine subscribe-Funktion. Du registrierst diesen Callback beim externen System. Wenn das System „Update!" sagt, rufst du callback() auf. NICHT umgekehrt.

getSnapshot MUSS Reference-stable sein bei gleichen Daten.

Der häufigste Bug-Vektor. () => ({a: x, b: y}) liefert jedes Mal neues Object → React sieht „neu" → Endlos-Loop oder unnötige Renders. Lösung: gleicher Wert → gleiche Object-Referenz.

getServerSnapshot für SSR — sonst Hydration-Mismatch.

Bei Server-Rendering läuft getSnapshot nicht (kein Browser-Store). Der dritte Parameter liefert den Server-Wert. Ohne diesen Parameter wirft useSyncExternalStore im SSR.

Concurrent-Sicher — kein Tearing.

Pre-18-Pattern (useEffect + useState) konnte unter Concurrent-Rendering inkonsistente Snapshots in verschiedenen Komponenten zeigen. useSyncExternalStore garantiert: alle Konsumenten sehen denselben Snapshot pro Render-Pass.

subscribe sollte STABIL sein — nicht bei jedem Render neu.

Wenn subscribe bei jedem Render eine neue Funktion ist, re-subscribt React jedes Mal. Daher: subscribe als Modul-Level-Funktion oder mit useCallback stabilisieren.

getSnapshot ist synchron — kein `await`, kein `fetch`.

Snapshot muss SOFORT verfügbar sein. Async-Quellen: erst in den Store laden, dann getSnapshot liest den geladenen Wert. Für Server-State: TanStack Query, das eigene Caching-Lösungen hat.

useSyncExternalStore ist die offizielle Lösung für 'Selectors'.

Redux useSelector nutzt es intern. Custom-Selectors mit Memoization (z.B. useMemo auf getSnapshot-Resultat) sind möglich, aber die Library-Hooks haben das oft bereits optimiert.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Hooks

Zur Übersicht