React Performance
Performance-Optimierungsmuster für React 18/19 und Next.js, adaptiert von Vercel Labs react-best-practices (MIT, v1.0.0). Dieses Skill organisiert Regeln nach Priorität und bietet eine Entscheidungsmatrix für aktive Code-Reviews und Refactoring.
Wann aktivieren
- Beim Schreiben oder Überprüfen von React/Next.js-Code auf Performance
- Diagnose langsamer Seitenladezeiten, träger Interaktionen oder hoher CPU-Auslastung auf dem Client
- Überprüfung der Bundle-Größe oder Regressionen bei Lighthouse Core Web Vitals
- Eliminierung von Wasserfällen in Server Components / API-Routen
- Reduzierung clientseitiger Re-Render
- Optimierung langer Listen, Animationen oder Hydration
- Überprüfung von Optimierungsentscheidungen in PRs, die
app/,pages/,components/oder Datenebenen betreffen
Prioritätsindex
| Priorität | Kategorie | Präfix | Wann relevant |
|---|---|---|---|
| 1 — KRITISCH | Wasserfälle eliminieren | async- | Immer wenn await von unabhängigem await gefolgt wird |
| 2 — KRITISCH | Bundle-Größe optimieren | bundle- | Erstlade-JS, Importe auf Routenebene, Drittanbieter-Bibliotheken |
| 3 — HOCH | Serverseitige Performance | server- | RSC, Server Actions, API-Routen, SSR |
| 4 — MITTEL-HOCH | Clientseitige Datenabfrage | client- | SWR / TanStack Query / rohes fetch in Hooks |
| 5 — MITTEL | Re-Render-Optimierung | rerender- | Hochfrequente Zustandsaktualisierungen, Eltern-Kind-Fan-out |
| 6 — MITTEL | Rendering-Performance | rendering- | Lange Listen, Animationen, Hydration |
| 7 — NIEDRIG-MITTEL | JavaScript-Performance | js- | Heiße Schleifen, häufige Allokationen |
| 8 — NIEDRIG | Fortgeschrittene Muster | advanced- | Effect-Event-Integration, stabile Referenzen |
1. Wasserfälle eliminieren (KRITISCH)
„Wasserfälle sind der Performance-Killer Nr. 1“ — jedes sequenzielle
awaitaddiert die vollständige Netzwerklatenz.
Günstige Bedingungen vor await
Prüfe synchrone Bedingungen (Props, Umgebungsvariablen, fest codierte Flags), bevor du entfernte Daten abwartest.
// FALSCH
async function Page({ id }: { id: string }) {
const flag = await getFlag("show-page");
if (!flag || !id) return null;
const data = await getData(id);
// ...
}
// RICHTIG — kurzschließen bei günstiger synchroner Bedingung zuerst
async function Page({ id }: { id: string }) {
if (!id) return null;
const flag = await getFlag("show-page");
if (!flag) return null;
const data = await getData(id);
}
Warte ab, bis es benötigt wird
Verschiebe await in den Zweig, der die Daten verwendet.
// FALSCH — wartet ab, bevor entschieden wird, ob die Daten benötigt werden
const user = await getUser(id);
if (mode === "guest") return renderGuest();
return renderUser(user);
// RICHTIG
if (mode === "guest") return renderGuest();
const user = await getUser(id);
return renderUser(user);
Promise.all für unabhängige Arbeit
// FALSCH — sequenziell
const user = await getUser(id);
const posts = await getPosts(id);
const followers = await getFollowers(id);
// RICHTIG — parallel
const [user, posts, followers] = await Promise.all([
getUser(id),
getPosts(id),
getFollowers(id),
]);
Teilabhängigkeiten — früh starten, spät abwarten
// RICHTIG — alle Promises starten, nur abwarten, wenn das jeweilige Ergebnis benötigt wird
const userP = getUser(id);
const postsP = getPosts(id);
const profile = await getProfile(id);
if (profile.private) return null;
const [user, posts] = await Promise.all([userP, postsP]);
Suspense für Streaming
Setze <Suspense>-Grenzen nah an den Daten, damit die Seite rendern kann, was möglich ist, während langsamere Unterbäume hereingestreamt werden. Der Kompromiss: Layout-Shift bei eintreffendem Inhalt — reserviere Platz (Skeleton oder min-height).
Server Components: Parallelisierung durch Komposition
// FALSCH — Geschwister-await läuft sequenziell innerhalb einer Komponente
export default async function Page() {
const user = await getUser();
const cart = await getCart();
return <View user={user} cart={cart} />;
}
// RICHTIG — in Kinder aufteilen, React führt sie parallel aus
export default async function Page() {
return (
<View>
<UserSection />
<CartSection />
</View>
);
}
2. Bundle-Größe optimieren (KRITISCH)
Direkte Importe, keine Barrels
Barrel-index.ts-Dateien zwingen den Bundler, den gesamten Modulgraph zu durchlaufen, selbst wenn Tree-Shaking den Großteil entfernt. Direkte Importe sparen in vielen realen Anwendungen 200-800 ms der Erstlade-JS.
// FALSCH
import { Button, Card, Modal } from "@/components";
// RICHTIG
import { Button } from "@/components/Button";
import { Card } from "@/components/Card";
import { Modal } from "@/components/Modal";
Next.js 13.5+ bietet Optimize Package Imports, das dies für gelistete Pakete automatisiert — nutze es; manuelle direkte Importe sind für nicht gelistete Bibliotheken weiterhin erforderlich.
Statisch analysierbare Pfade
// FALSCH — verhindert Bundler-/Trace-Analyse
const mod = await import(`./pages/${name}`);
// RICHTIG — explizit pro Zweig
const mod = name === "home" ? await import("./pages/home") : await import("./pages/about");
Dynamische Importe für schwere Komponenten
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <Skeleton />,
ssr: false, // wenn client-only
});
Drittanbieter-Skripte verzögern
Lade Analytics, Logging, Support-Widgets NACH der Hydration. Verwende next/script mit strategy="afterInteractive" (Standard) oder "lazyOnload".
Bedingtes Laden von Modulen
if (user.role === "admin") {
const { AdminPanel } = await import("./admin/AdminPanel");
// ...
}
Vorladen bei Hover/Fokus
Löse <link rel="preload"> oder import() bei Hover aus, sodass das Bundle im Cache ist, wenn der Benutzer klickt.
3. Serverseitige Performance (HOCH)
Server Actions wie API-Routen authentifizieren
Jede "use server"-Funktion ist ein öffentlicher Endpunkt. Authentifiziere UND autorisiere innerhalb der Action — verlasse dich niemals auf die Absicherung der aufrufenden Client-Komponente.
"use server";
export async function deleteUser(formData: FormData) {
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");
const targetId = String(formData.get("id"));
if (session.user.role !== "admin" && session.user.id !== targetId) {
throw new Error("Forbidden");
}
await db.user.delete({ where: { id: targetId } });
}
React.cache() für anforderungsbezogene Deduplizierung
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
React.cache dedupliziert innerhalb einer einzelnen Anforderung. Der Aufruf von getUser("1") aus drei Server-Komponenten im selben Render = eine DB-Abfrage.
LRU-Cache für anforderungsübergreifende Daten
Für Daten, die sich NICHT pro Anforderung ändern (Konfiguration, Nachschlagetabellen), cachen außerhalb von React mit einem LRU-Cache oder unstable_cache.
Doppelte Serialisierung in RSC-Props vermeiden
Wenn eine Server-Komponente dieselben Daten in mehrere Client-Komponenten rendert, werden die Daten einmal pro Verbraucher serialisiert. Hebe die Client-Komponente an und übergib Kinder.
Statische I/O auf Modulebene heben
// RICHTIG — wird einmal beim Laden des Moduls ausgeführt
const fontData = readFileSync(fontPath);
export async function Page() {
return <Banner font={fontData} />;
}
Kein veränderlicher Modulzustand in RSC/SSR
Modulzustand auf dem Server wird über alle Anforderungen hinweg geteilt — eine Race-Condition zwischen Benutzern. Verwende stattdessen anforderungsbezogenen Speicher (headers(), cookies(), asynchroner Kontext).
Daten, die an Client-Komponenten übergeben werden, minimieren
Serialisiere nur, was der Client benötigt. Entferne Felder, paginiere, projiziere Spalten auf der DB-Ebene.
Verschachtelte Abfragen mit Promise.all pro Element parallelisieren
const users = await getUsers();
const enriched = await Promise.all(
users.map(async (u) => ({ ...u, posts: await getPostsFor(u.id) })),
);
after() für nicht blockierende Arbeit verwenden
Next.js 15 after() führt Arbeit aus, nachdem die Antwort gesendet wurde — Logging, Cache-Warming, Analytics.
import { after } from "next/server";
export async function GET() {
const data = await getData();
after(() => logAnalytics(data));
return Response.json(data);
}
4. Clientseitige Datenabfrage (MITTEL-HOCH)
SWR / TanStack Query für Deduplizierung
Mehrere Komponenten, die useUser(id) aufrufen, sollten eine einzige Netzwerkanfrage und einen Cache-Eintrag gemeinsam nutzen. Verwende SWR oder TanStack Query — implementiere niemals dein eigenes useEffect + fetch für gemeinsam genutzte Daten.
Globale Event-Listener deduplizieren
// FALSCH — jede Komponente fügt ihren eigenen hinzu
useEffect(() => {
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
// RICHTIG — einzelner gemeinsam genutzter Listener via Hook + globales Subject
const useScroll = createScrollHook(); // Singleton-Subject unter der Haube
Passive Listener für Scroll
window.addEventListener("scroll", handler, { passive: true });
Verbessert die Scroll-Flüssigkeit; der Listener kann preventDefault() nicht aufrufen.
localStorage: Version + minimieren
- Speichere immer ein
version-Feld; erhöhe bei Schemaänderung und migriere oder verwerfe alte Daten - Halte die Nutzlasten klein —
localStorageist synchron und blockiert den Hauptthread
5. Re-Render-Optimierung (MITTEL)
Kein Zustand abonnieren, der nur in Callbacks verwendet wird
// FALSCH — rendert jedes Mal neu, wenn sich count ändert
const count = useStore((s) => s.count);
const handler = () => doSomething(count);
// RICHTIG — einmal beim Aufruf lesen
const handler = () => {
const count = useStore.getState().count;
doSomething(count);
};
Teure Arbeit in memoized Komponenten auslagern
// RICHTIG — Kind rendert nur neu, wenn sich `items` ändert
const Heavy = memo(function Heavy({ items }: { items: Item[] }) {
return <Chart data={transform(items)} />;
});
Standardmäßige nicht-primitive Props anheben
// FALSCH — neues Array pro Render unterbricht memo
<List items={items ?? []} />
// RICHTIG
const EMPTY: Item[] = [];
<List items={items ?? EMPTY} />
Primitive Abhängigkeiten in Effekten
// FALSCH — neue Objektidentität pro Render
useEffect(() => {}, [{ id, name }]);
// RICHTIG — Primitiven
useEffect(() => {}, [id, name]);
Abgeleitete Booleans abonnieren, nicht Rohwerte
// FALSCH — rendert bei jeder Warenkorbänderung neu
const cart = useStore((s) => s.cart);
const hasItems = cart.length > 0;
// RICHTIG — rendert nur neu, wenn sich der Leerzustand ändert
const hasItems = useStore((s) => s.cart.length > 0);
Während des Renders ableiten, niemals via useEffect
// FALSCH
const [full, setFull] = useState("");
useEffect(() => setFull(`${first} ${last}`), [first, last]);
// RICHTIG
const full = `${first} ${last}`;
Funktionale setState für stabile Callbacks
// RICHTIG
const increment = useCallback(() => setCount((c) => c + 1), []);
Lazy-Zustandsinitialisierer für teure Werte
const [tree] = useState(() => parseTree(largeInput));
Memo für einfache Primitiven vermeiden
useMemo(() => x + 1, [x]) ist Overhead. Memo lohnt sich bei Objektidentität und teuren Berechnungen.
Hooks mit unabhängigen Abhängigkeiten aufteilen
// FALSCH — beide Selektoren laufen erneut, wenn sich eine Quelle ändert
const { a, b } = useSomething(source1, source2);
// RICHTIG
const a = useA(source1);
const b = useB(source2);
Interaktionslogik in Event-Handler verschieben
Event-Handler werden nur bei der Benutzeraktion ausgeführt — useEffect wird immer dann erneut ausgeführt, wenn sich Abhängigkeiten ändern.
startTransition für nicht dringende Aktualisierungen
const [pending, startTransition] = useTransition();
startTransition(() => setFilters(newFilters));
useDeferredValue für teure Render
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
useRef für vorübergehende häufige Werte
Für Werte, die sich oft ändern, aber kein erneutes Rendern auslösen sollen (Zeitstempel, letzte Taste, Akkumulatoren).
Keine Komponenten innerhalb von Komponenten definieren
// FALSCH — Inner ist bei jedem Outer-Render eine neue Komponente
function Outer() {
const Inner = () => <span />;
return <Inner />;
}
Jeder Render erzeugt einen neuen Inner-Typ, was die Reconciliation untergräbt und Kinder aushängt.
6. Rendering-Performance (MITTEL)
Den Wrapper animieren, nicht das SVG
Das Transformieren eines <div>-Wrappers um ein SVG ist GPU-beschleunigt; das Transformieren des SVGs selbst löst Paint aus.
content-visibility: auto für lange Listen
.row { content-visibility: auto; contain-intrinsic-size: auto 80px; }
Der Browser überspringt das Rendern außerhalb des Bildschirms — großer Gewinn für Listen mit Hunderten von Zeilen.
Statisches JSX anheben
const STATIC_HEADER = <h1>Titel</h1>;
function Page() {
return <>{STATIC_HEADER}<Body /></>;
}
SVG: Koordinatenpräzision reduzieren
d="M10.123456,20.654321" → d="M10.12,20.65". Jede Ziffer kostet Bytes; der visuelle Unterschied ist sub-Pixel.
Hydration ohne Flackern via Inline-Skript
Für Werte, die vor der Hydration benötigt werden (Theme, Gebietsschema), ein <script> inline einfügen, das document.documentElement.dataset.* setzt, bevor React mountet.
Erwartete Hydration-Mismatches eng unterdrücken
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
Nur für bekanntermaßen abweichende Blattknoten verwenden — niemals in einem Baum, der andere Kinder enthält.
<Activity> für Ein-/Ausblenden statt Mount-/Unmount
React 19 <Activity mode="visible|hidden"> behält den Baumzustand und Effekte bei, blendet aber aus — billiger als Unmount/Remount für Tabs und Akkordeons.
Ternärer Operator über && für bedingtes Rendern
// FALSCH — `0` wird als Textknoten gerendert
{count && <Badge>{count}</Badge>}
// RICHTIG
{count > 0 ? <Badge>{count}</Badge> : null}
useTransition für Ladezustände
startTransition mit der Aktion paaren; React zeigt die vorherige UI als isPending, während der nächste Zustand berechnet wird.
React DOM-Ressourcen-Hinweise
import { preload, preconnect } from "react-dom";
preload("/api/critical", { as: "fetch" });
preconnect("https://api.example.com");
…
[truncated for translation]