2026

4. Februar 2026

use(): Der Hook, der die Regeln bricht (mit Absicht)

Reacts use()-Hook liest Promises und Context zur Renderzeit, integriert sich mit Suspense und beseitigt das häufigste useEffect-Anti-Pattern. Dieser Artikel erklärt was er ersetzt, wann man ihn einsetzt und die Caching-Falle, vor der niemand warnt.

S
Sascha Becker
Author

17 Min. Lesezeit

use(): Der Hook, der die Regeln bricht (mit Absicht)

use(): Der Hook, der die Regeln bricht (mit Absicht)

Jeder React-Entwickler hat diesen Code geschrieben:

tsx
const [data, setData] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetchUser(id)
.then((user) => {
if (!cancelled) setData(user);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [id]);

Drei State-Variablen. Ein Cleanup-Flag. Ein Dependency-Array. Eine Race Condition, über die man jedes einzelne Mal nachdenken muss. Und das ist die korrekte Version: die meisten Codebasen überspringen das cancelled-Flag und das Error-Handling komplett.

Dieses Pattern ist nicht falsch. Es funktioniert. Aber es ist Boilerplate, das existiert, weil React keine eingebaute Möglichkeit hatte zu sagen: „Warte auf dieses Promise, dann rendere." Jede Komponente, die Daten abruft, musste dieselbe Loading/Error/Data-Zustandsmaschine von Grund auf neu erfinden.

React 19 hat use() eingeführt, um das zu lösen. Es ist der erste Hook, der in Bedingungen und Schleifen aufgerufen werden kann, er integriert sich direkt mit Suspense, und er verwandelt das Fetch-dann-setState-Pattern in eine einzige Zeile.

Was use() macht

use() liest einen Wert aus einer Ressource zur Renderzeit. Die Ressource kann ein Promise oder ein Context sein.

tsx
import { use } from "react";
// Promise lesen - suspendiert bis aufgeloest
const user = use(userPromise);
// Context lesen - wie useContext, aber in Bedingungen aufrufbar
const theme = use(ThemeContext);

Das ist die gesamte API. Eine Funktion, zwei Anwendungsfälle.

Wenn du ein Promise übergibst, integriert sich use() mit der nächsten <Suspense>-Boundary. Solange das Promise aussteht, suspendiert die Komponente. React zeigt den Suspense-Fallback. Wenn es sich auflöst, rendert React mit dem aufgelösten Wert neu. Wenn es fehlschlägt, fängt die nächste Error Boundary den Fehler.

Kein useState. Kein useEffect. Kein isLoading. Kein setData. React übernimmt alles.

Das Pattern, das es ersetzt

Packe das Snippet von oben in eine Komponente und füge die obligatorischen Loading/Error-Guards hinzu:

tsx
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
/* ... fetch, cancelled flag, setState ... */
}, [userId]);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return <ProfileCard user={user} />;
}

Drei State-Deklarationen, ein Effect, drei bedingte Returns: alles bevor man die eigentliche UI erreicht. Jede Komponente, die Daten abruft, wiederholt diese Struktur.

Dieselbe Komponente mit use():

tsx
// Client Component - nur der Happy Path
"use client";
import { use } from "react";
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <ProfileCard user={user} />;
}
tsx
// Server Component - erstellt das Promise und definiert die Boundaries
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
export default function UserPage({ params }: { params: { id: string } }) {
const userPromise = fetchUser(params.id);
return (
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
);
}

Der Ladezustand wird von <Suspense> behandelt. Der Fehlerzustand wird von <ErrorBoundary> behandelt (aus dem react-error-boundary-Paket). Die Komponente selbst enthält nur den Happy Path - den Code, der läuft, wenn Daten verfügbar sind. Die Zustandsmaschine wurde von deinem Code in Reacts Runtime verlagert.

Da UserPage eine Server-Komponente ist, rendert sie nicht neu. Die Promise-Referenz wird einmal erstellt und als stabiler Prop weitergereicht, keine Caching-Akrobatik nötig.

Wie Suspense reinpasst

use() funktioniert nicht isoliert. Es ist ein Teil einer dreiteiligen Architektur:

  1. use(promise): suspendiert die Komponente, solange das Promise aussteht.
  2. <Suspense fallback={...}>: fängt die Suspension ab und zeigt eine Fallback-UI.
  3. <ErrorBoundary fallback={...}>: fängt abgelehnte Promises und zeigt eine Fehler-UI.

Ohne eine Suspense-Boundary darüber wird eine Komponente, die use() mit einem ausstehenden Promise aufruft, abstürzen. Die Boundary ist nicht optional.

tsx
<ErrorBoundary fallback={<p>Etwas ist schiefgelaufen.</p>}>
<Suspense fallback={<p>Laden...</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
Verschachtelte Boundaries

Du kannst Suspense-Boundaries verschachteln, um gestaffelte Ladesequenzen zu erstellen:

tsx
<Suspense fallback={<PageSkeleton />}>
<Header userPromise={userPromise} />
<Suspense fallback={<FeedSkeleton />}>
<Feed postsPromise={postsPromise} />
</Suspense>
</Suspense>

Wenn Header vor Feed auflöst, erscheint der Header sofort, während der Feed noch sein Skeleton zeigt. Jede Boundary steuert eine andere Ladezone. Das ist deklarative Lade-Orchestrierung, du beschreibst die Struktur, nicht das Timing.

Inhalte gemeinsam aufdecken

Alle Kinder innerhalb einer einzelnen Suspense-Boundary werden als Einheit behandelt. Wenn ein Kind suspendiert, zeigt die gesamte Boundary ihren Fallback. Das ist nützlich, wenn mehrere Daten gleichzeitig erscheinen sollen:

tsx
<Suspense fallback={<DashboardSkeleton />}>
<Stats statsPromise={statsPromise} />
<Chart chartPromise={chartPromise} />
<RecentActivity activityPromise={activityPromise} />
</Suspense>

Alle drei Komponenten „poppen" zusammen rein, sobald jedes Promise aufgelöst ist. Keine Teilzustände, kein Layout-Shift.

Das Caching-Problem

Hier hören die meisten Tutorials auf. Aber wenn du versuchst, use() mit einem Promise zu verwenden, das innerhalb einer Client-Komponente erstellt wird, wirst du auf einen subtilen und frustrierenden Bug stoßen.

tsx
// Bug: Erstellt ein neues Promise bei jedem Render
function UserProfile({ userId }: { userId: string }) {
const user = use(fetchUser(userId)); // neues Promise bei jedem Render
return <ProfileCard user={user} />;
}

fetchUser(userId) gibt bei jedem Render ein neues Promise-Objekt zurück. React sieht ein neues Promise, suspendiert erneut, die Komponente rendert neu, erstellt ein weiteres neues Promise, suspendiert erneut. Endlosschleife.

Wie man das Promise stabilisiert

Es gibt mehrere Ansätze, jeder passend für eine andere Architektur:

1. Das Promise im Parent oder in einer Server-Komponente erstellen

Das ist das empfohlene Pattern. Der Parent erstellt das Promise einmalig und reicht es als Prop weiter:

tsx
// Server Component - Promise einmal erstellt, stabil über Renders
export default function UserPage({ params }: { params: { id: string } }) {
const userPromise = fetchUser(params.id);
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}

Kein async/await nötig, das Promise wird unaufgelöst weitergereicht. Die Client-Komponente entpackt es mit use(). Server-Komponenten rendern nicht neu, daher ist die Promise-Referenz von Natur aus stabil.

2. Einen Cache auf Modul-Ebene verwenden

Für Client-Komponenten, die Fetches initiieren müssen, cache das Promise, damit bei weiteren Aufrufen dieselbe Referenz zurückgegeben wird:

tsx
const cache = new Map<string, Promise<User>>();
function fetchUserCached(id: string): Promise<User> {
if (!cache.has(id)) {
cache.set(id, fetchUser(id));
}
return cache.get(id)!;
}
function UserProfile({ userId }: { userId: string }) {
const user = use(fetchUserCached(userId));
return <ProfileCard user={user} />;
}

Gleiche Argumente ergeben dieselbe Promise-Referenz. Keine Endlosschleife.

3. Eine Data-Fetching-Bibliothek verwenden

Bibliotheken wie TanStack Query oder SWR übernehmen Caching, Deduplizierung und Revalidierung von Haus aus. Sie existierten vor use() und lösen ein viel breiteres Problem - bringen aber auch ~13kB gzipped und einen Provider-Wrapper mit. Für ein einfaches "einmal fetchen, Ergebnis anzeigen"-Pattern reicht use() mit einer 5-Zeilen Cache-Funktion (Option 2 oben) völlig aus. Die Library lohnt sich, wenn die UI langlebigen Client-State hat, der aktuell bleiben muss: etwa Dashboards die bei Tab-Fokus refetchen, Listen mit Pagination oder Mutationen die verwandte Queries optimistisch updaten sollen.

4. Reacts cache() in Server-Komponenten verwenden

React bietet eine eingebaute cache()-Funktion für Server-Komponenten. Sie memoized den Rückgabewert einer Funktion für die Dauer eines einzelnen Server-Requests:

tsx
import { cache } from "react";
const getUser = cache(async (id: string): Promise<User> => {
const res = await fetch(`/api/users/${id}`);
return res.json();
});

Mehrere Komponenten, die getUser("123") während desselben Server-Renders aufrufen, teilen sich einen Fetch. Der Cache ist auf den Request beschränkt, er wird bei jedem neuen Seitenaufruf zurückgesetzt.

use() für Context

use() kann auch Context lesen, und hier bricht es eine Regel, die jeder andere Hook befolgt.

Jeder React-Hook muss auf der obersten Ebene einer Komponente aufgerufen werden, nie in Bedingungen, Schleifen oder nach frühen Returns. use() ist die Ausnahme. Es kann bedingt aufgerufen werden:

tsx
function Greeting({ showFormal }: { showFormal: boolean }) {
if (showFormal) {
const { locale } = use(I18nContext);
return <p>{locale === "de" ? "Guten Tag" : "Good day"}</p>;
}
return <p>Hey!</p>;
}

Mit useContext würde dieser Code die Regeln der Hooks verletzen. Mit use() ist er gültig. Reacts Linter kennt diese Ausnahme.

Das ist relevant für Performance. Wenn eine Komponente Context nur in bestimmten Code-Pfaden braucht, lässt use() dich das Lesen komplett überspringen, wenn die Bedingung falsch ist. Mit useContext abonniert die Komponente diesen Context bedingungslos, selbst wenn sie den Wert nicht braucht.

Wann was verwenden

Die Landschaft des Data Fetchings in React hat mehr Optionen als je zuvor. Hier ist, wann welcher Ansatz der richtige ist:

SzenarioAnsatz
Server-Komponente ruft Daten abasync/await direkt in der Komponente
Async-Daten von Server- an Client-Komponente übergebenPromise auf dem Server erstellen, use() auf dem Client mit Suspense
Einfacher Client-Fetch (Popup, Dialog, einmalige Anzeige)use() mit gecachtem Promise + Suspense
Komplexer Client-State (Auto-Refetch, Pagination, Mutationen)TanStack Query oder SWR
Berechneter/abgeleiteter State aus Props oder anderem StateuseMemo oder direkte Berechnung während des Renderings
Context bedingt lesenuse(SomeContext)
Context bedingungslos lesenuseContext(SomeContext): einfacher, vertrauter
Externes Store abonnieren (Redux, Zustand, Browser-API)useSyncExternalStore
DOM-Messungen, Event-Listener, TimeruseEffect: dafür ist es tatsächlich gedacht

Migration weg von useEffect + setState

Wenn du eine bestehende Codebasis voller useEffect-Fetch-setState-Pattern hast, musst du nicht alles auf einmal umschreiben. Hier ist ein praktischer Migrationspfad:

Schritt 1: In einen Custom Hook extrahieren

Bevor du den Data-Fetching-Mechanismus änderst, kapsel das bestehende Pattern:

tsx
function useUser(id: string) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetchUser(id)
.then((data) => {
if (!cancelled) setUser(data);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [id]);
return { user, isLoading, error };
}

Das ändert den Mechanismus nicht, gibt dir aber eine einzige Stelle, an der du die Implementierung später austauschen kannst.

Schritt 2: Suspense-Boundaries hinzufügen

Umwickle die konsumierenden Komponenten mit Suspense und Error Boundaries. Das ist sicher, auch bevor du zu use() wechselst: die Boundaries triggern einfach noch nicht:

tsx
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Skeleton />}>
<UserProfile userId={userId} />
</Suspense>
</ErrorBoundary>
Schritt 3: Die Interna austauschen

Ändere jetzt den Custom Hook (oder die Komponente), sodass er ein Promise akzeptiert und use() verwendet. Die konsumierenden Komponenten ändern sich nicht, sie haben bereits Suspense-Boundaries:

tsx
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <ProfileCard user={user} />;
}

Der alte useUser-Hook kann gelöscht werden. Die drei State-Variablen, der Effect und das Cleanup-Flag sind weg. Die Boundary übernimmt Loading und Errors.

Schritt 4: Den Fetch nach oben verschieben

Verschiebe die Promise-Erstellung in Server-Komponenten oder in eine Caching-Schicht. Das ist der eigentliche architektonische Wandel, die Daten-Initiierung bewegt sich von der Komponente, die sie braucht, zur Komponente (oder zum Server), die eine stabile Referenz erstellen kann.

Regeln und Stolperfallen

use() kann bedingt aufgerufen werden

Anders als jeder andere Hook funktioniert use() in if, for und nach frühen Returns. Reacts Linter kennt diese Ausnahme.

use() kann nicht in try-catch aufgerufen werden

Abgelehnte Promises werden von Error Boundaries gefangen, nicht von try-catch-Blöcken. Wenn du use() in ein try-catch wickelst, wirft React einen „Suspense Exception"-Fehler.

tsx
// Das wird abstuerzen
try {
const data = use(promise);
} catch (e) {
// Wird nie erreicht
}

Wenn du einen Fallback-Wert für ein abgelehntes Promise bereitstellen musst, verwende .catch() auf dem Promise selbst:

tsx
const safePromise = riskyFetch().catch(() => defaultValue);
const data = use(safePromise);
Aufgelöste Werte müssen serialisierbar sein (Server zu Client)

Wenn du ein Promise von einer Server-Komponente an eine Client-Komponente übergibst, muss der aufgelöste Wert serialisierbar sein, keine Funktionen, keine Klassen-Instanzen, keine Symbols. Primitive, einfache Objekte und Arrays sind in Ordnung.

Mische keine Patterns für dieselben Daten

Wenn du ein Promise mit use() liest, rufe nicht auch dieselben Daten in einem useEffect ab. Wähle eine Quelle der Wahrheit für jedes Datenstück.

Ein vollständiges Beispiel

Hier ist ein realistisches Beispiel, ein Dashboard, das ein Benutzerprofil und die letzten Bestellungen parallel lädt, mit gestaffeltem Loading:

tsx
// Server Component
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { fetchUser, fetchOrders } from "@/lib/api";
import { Dashboard } from "./Dashboard";
export default function DashboardPage({ params }: { params: { id: string } }) {
// Beide Fetches starten gleichzeitig - kein Wasserfall
const userPromise = fetchUser(params.id);
const ordersPromise = fetchOrders(params.id);
return (
<ErrorBoundary fallback={<p>Etwas ist schiefgelaufen.</p>}>
<Suspense fallback={<HeaderSkeleton />}>
<Dashboard userPromise={userPromise} ordersPromise={ordersPromise} />
</Suspense>
</ErrorBoundary>
);
}
tsx
// Client Component
"use client";
import { use, Suspense } from "react";
export function Dashboard({
userPromise,
ordersPromise,
}: {
userPromise: Promise<User>;
ordersPromise: Promise<Order[]>;
}) {
const user = use(userPromise);
return (
<div>
<h1>Willkommen zurück, {user.name}</h1>
<Suspense fallback={<OrdersSkeleton />}>
<OrderList ordersPromise={ordersPromise} />
</Suspense>
</div>
);
}
function OrderList({ ordersPromise }: { ordersPromise: Promise<Order[]> }) {
const orders = use(ordersPromise);
if (orders.length === 0) return <p>Keine aktuellen Bestellungen.</p>;
return (
<ul>
{orders.map((order) => (
<li key={order.id}>
{order.item}{order.status}
</li>
))}
</ul>
);
}
Was zur Laufzeit passiert

Der folgende Ablauf zeigt die genaue Reihenfolge, was wann rendert und was der Benutzer in jeder Phase sieht:

DIAGRAM

Die zentrale Erkenntnis: use() gibt nie einen undefined oder ausstehenden Wert zurück. Wenn user.name ausgeführt wird, ist das Promise bereits aufgelöst. Solange es aussteht, rendert die Komponente einfach nicht, die nächste Suspense-Boundary zeigt stattdessen ihren Fallback.

Was ist mit Client-seitigem Fetching?

Alle bisherigen Beispiele starten den Fetch in einer Server Component und reichen das Promise nach unten. Aber was, wenn man bereits tief in einer Client Component steckt, zum Beispiel ein Button öffnet ein Popup, das neue Daten braucht?

Man kann keine Server Component innerhalb einer Client Component rendern. Aber use() + Suspense funktioniert trotzdem, man muss nur die Promise-Identität selbst verwalten.

Den Fetch im Event Handler starten

Der direkteste Ansatz: das Promise im Click-Handler erzeugen, im State speichern und use() lesen lassen.

tsx
"use client";
function DetailPopup({ dataPromise }: { dataPromise: Promise<ItemDetail> }) {
const detail = use(dataPromise);
return <div>{detail.description}</div>;
}
function ItemCard({ itemId }: { itemId: string }) {
const [promise, setPromise] = useState<Promise<ItemDetail> | null>(null);
const handleOpen = () => {
setPromise(fetchItemDetail(itemId)); // Fetch startet sofort
};
return (
<>
<button onClick={handleOpen}>Details anzeigen</button>
{promise && (
<Suspense fallback={<Skeleton />}>
<DetailPopup dataPromise={promise} />
</Suspense>
)}
</>
);
}

Das ist reaktionsschnell, der Fetch startet in dem Moment, in dem der Benutzer klickt, nicht erst wenn React einen Render plant. Jeder Klick erzeugt ein neues Promise, sodass man immer aktuelle Daten bekommt.

Modul-Level Cache für wiederholten Zugriff

Wenn dasselbe Popup mehrfach mit derselben ID geöffnet werden könnte, vermeidet ein Cache redundante Requests:

ts
const cache = new Map<string, Promise<ItemDetail>>();
export function getItemDetail(id: string) {
if (!cache.has(id)) {
cache.set(
id,
fetch(`/api/items/${id}`).then((r) => r.json()),
);
}
return cache.get(id)!;
}
tsx
const handleOpen = () => {
setPromise(getItemDetail(itemId));
};
Wann reicht use(), und wann braucht man mehr?

Für das Popup-Szenario oben. Benutzer klickt, Daten laden, Popup zeigt sie an, reicht use() mit einer einfachen Cache-Funktion völlig aus. Keine Extra-Dependency, kein Provider, keine Konfiguration. Der 5-Zeilen Map-Cache von oben erledigt die Deduplizierung.

Ziehe TanStack Query oder SWR in Betracht, wenn die Daten einen Lebenszyklus über eine einzelne Anzeige hinaus haben:

  • Dieselben Daten werden an mehreren Stellen angezeigt und eine Mutation an einer Stelle soll alle anderen aktualisieren
  • Daten werden stale und sollen still refetchen, wenn der Benutzer zum Tab zurückkehrt
  • Du brauchst paginierte oder Infinite-Scroll-Listen mit Cursor-Tracking
  • Du willst optimistisches UI, das bei Server-Fehler zurückrollt

Wenn nichts davon zutrifft, ist use() + eine Cache-Funktion die einfachere Wahl. Man kann später jederzeit eine Library hinzufügen, wenn die Caching-Anforderungen wachsen, das mentale Modell (Promise rein, Daten raus, Suspense handhabt das Warten) bleibt dasselbe.


S
Geschrieben von
Sascha Becker
Weitere Artikel