React Compiler: Manuelles useMemo und useCallback adé?
1. Überblick
Viele React-Projekte haben Teile mit useMemo, useCallback und React.memo, die über die gesamte Codebase verteilt sind. Das Ziel ist es, unnötige Re-Renders zu vermeiden, aber der Preis ist längerer Code und leicht zu machende Fehler bei den Dependencies. React Compiler verlagert diese Memoization auf die Build-Phase: Es analysiert Komponenten und fügt automatisch Cache an den nötigen Stellen ein.
Dieser Artikel erklärt, was React Compiler ist und warum es Aufmerksamkeit erhält. Du wirst lernen, wie du den Compiler in einem Vite-Projekt aktivierst, wie du schrittweise Teile migrierst, wie du Tests für das Memoization-Verhalten schreibst, und in welchen Fällen du es vermeiden solltest.
2. Probleme mit manuellem Memoization
Betrachte eine Seite mit einer Bestellliste, die ein Filterfeld und einen nicht verwandten Zähler hat:
import { useState, useMemo, useCallback, memo } from "react";
type Order = { id: number; customer: string; total: number };
const OrderList = memo(function OrderList({
orders,
onSelect,
}: {
orders: Order[];
onSelect: (id: number) => void;
}) {
return (
<ul>
{orders.map((o) => (
<li key={o.id} onClick={() => onSelect(o.id)}>
{o.customer}: {o.total}
</li>
))}
</ul>
);
});
export function OrdersPage({ orders }: { orders: Order[] }) {
const [keyword, setKeyword] = useState("");
const [clicks, setClicks] = useState(0);
const filtered = useMemo(
() => orders.filter((o) => o.customer.includes(keyword)),
[orders, keyword]
);
const handleSelect = useCallback((id: number) => {
console.log("Selected order", id);
}, []);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
<OrderList orders={filtered} onSelect={handleSelect} />
</>
);
}
Der Code funktioniert richtig, aber es gibt drei fehleranfällige Stellen:
- Wenn du
memobeiOrderListweglässt, sind beideuseMemounduseCallbackpraktisch nutzlos. - Wenn du
keywordaus dem Dependency-Array vergisst, zeigt die gefilterte Liste veraltete Daten an. - Es reicht ein neuer Prop mit einem Object-Literal wie
style={{ color: "red" }}, um all deine Memoization-Arbeit zunichte zu machen.
Manuelles Memoization ist ein implizites System. Jeder, der eine Komponente ändert, muss es verstehen, und Fehler werden meist erst sichtbar, wenn jemand die Performance analysiert.
3. Was ist React Compiler
React Compiler ist ein Build-Zeit-Tool, normalerweise als Babel-Plugin. Es liest den Komponenten- und Hook-Code, versteht den Datenfluss und generiert dann Code mit Cache für:
- Berechnete Werte: äquivalent zu
useMemo. - Funktionen: äquivalent zu
useCallback. - JSX-Elemente: äquivalent zum Wrappen von Child-Komponenten mit
memo. Wenn sich die Eingaben eines Elements nicht ändern, stellt der Compiler das alte Element wieder her und React überspringt das erneute Rendern des gesamten Subtrees.
Der folgende Codeblock veranschaulicht das Konzept des Codes nach der Kompilation. Dies ist eine vereinfachte Version, nicht die echte Ausgabe:
function OrdersPage({ orders }) {
const $ = useMemoCache(6); // Cache-Array gebunden an die Component-Instanz
const [keyword, setKeyword] = useState("");
let filtered;
if ($[0] !== orders || $[1] !== keyword) {
filtered = orders.filter((o) => o.customer.includes(keyword));
$[0] = orders;
$[1] = keyword;
$[2] = filtered;
} else {
filtered = $[2];
}
// ... ähnlich für handleSelect und JSX-Elemente
}
Der Compiler vergleicht jede Eingabe mit dem Wert aus dem vorherigen Render. Nur wenn sich eine Eingabe ändert, berechnet er neu. Im Vergleich zu manuellem Memo gibt es zwei wichtige Unterschiede:
- Granularer: Der Compiler kann auch Code memoizen, der nach bedingten Anweisungen kommt, was
useMemonicht kann, da Hooks nicht bedingt aufgerufen werden dürfen. - Keine manuellen Dependency-Arrays: Dependencies werden aus dem Code hergeleitet, daher kannst du sie nicht „vergessen”.
4. Warum React Compiler wichtig wird
Es gibt mehrere Gründe, warum dieses Thema viel beachtet wird:
- Weniger Boilerplate: Code wird zu „reinem React”, leichter zu lesen und zu überprüfen.
- Bessere Standard-Performance: Das Team braucht keinen Performance-Profi, um unnötige Re-Renders zu vermeiden.
- Erzwingt Einhaltung der Rules of React: Der Compiler kann nur „regelkonformen” Code optimieren. Daher wird ein Linter mitgebracht, der regelwidrigen Code erkennt, auch wenn du den Compiler noch nicht aktiviert hast.
- Schrittweise Kompatibilität: Du kannst es für ein Verzeichnis oder eine Komponente aktivieren, ohne die ganze Anwendung umzuschreiben.
Hinweis: Der Compiler macht Code nicht überall schneller. Er reduziert wiederholte Render-Arbeiten. Wenn dein Bottleneck Netzwerk, Bundle-Größe oder eine schwere Schleife ist, die jedes Mal ausgeführt wird, wenn sich eine Eingabe ändert, kann der Compiler das nicht beheben.
5. React Compiler in deinem Projekt aktivieren
5.1. Installation mit Vite
Installiere das Plugin und den Linter. Package-Namen können sich mit der Zeit ändern, daher solltest du die offizielle React-Dokumentation für deine Version überprüfen.
npm install -D babel-plugin-react-compiler eslint-plugin-react-hooks
Füge das Plugin zur Vite-Konfiguration hinzu:
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", {}]],
},
}),
],
});
Der Compiler sollte zuerst in der Kette der Babel-Plugins laufen, da er den ursprünglichen Code lesen muss, bevor andere Plugins ihn verändern.
Wenn dein Projekt noch eine ältere Version von React nutzt, benötigt der Compiler ein zusätzliches Runtime-Package und die target-Option, um kompatiblen Code zu generieren. Das hängt von der Version ab, daher schau in der Dokumentation nach, bevor du es konfigurierst.
Bei Frameworks wie Next.js oder Expo gibt es normalerweise eigene Konfigurationsflags zum Aktivieren des Compilers. Die Flag-Namen haben sich zwischen Versionen geändert, daher solltest du die Release Notes des Frameworks lesen, anstatt alte Konfigurationen zu kopieren.
5.2. Überprüfe, ob der Compiler aktiv ist
Öffne die React DevTools. Von dem Compiler optimierte Komponenten werden ein „Memo ✨“-Label neben dem Namen haben. Wenn dieses Label fehlt, hat der Compiler die Komponente übersprungen, normalerweise wegen einer Regelverletzung in Punkt 6.
5.3. Aktiviere Linting
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
export default [
reactHooks.configs.recommended,
];
Der Linter warnt vorab vor Code, den der Compiler überspringt oder der möglicherweise falsch läuft. Du solltest Linting vor dem Compiler aktivieren und alle Warnungen beheben.
6. Rules of React: Bedingungen für den Compiler
Der Compiler geht davon aus, dass Komponenten reine Funktionen beim Rendern sind. Das folgende Beispiel verletzt dies:
export function CartSummary({ items }: { items: { price: number }[] }) {
items.sort((a, b) => b.price - a.price); // modifiziere Prop!
const total = items.reduce((sum, i) => sum + i.price, 0);
return <p>Top item: {items[0]?.price}, total: {total}</p>;
}
items.sort modifiziert direkt das übergebene Array. Mit Cache könnte der Compiler nicht erkennen, dass items sich „geändert” hat, da die Referenz gleich bleibt, und die UI zeigt falsche Daten an. Der Linter warnt etwa:
error Mutating a value returned from a function whose return value should not be mutated react-hooks/...
Die genaue Nachricht hängt von der Plugin-Version ab. Um es zu beheben, erstelle eine Kopie:
const sorted = [...items].sort((a, b) => b.price - a.price);
Häufige Verletzungen:
- Ändern von Props, State oder Werten, die von Hooks zurückgegeben werden.
- Lesen oder Schreiben von
ref.currentwährend des Renderns. - Bedingte oder schleifengesteuerte Hook-Aufrufe.
- Direkte Nebeneffekte im Component-Body, z.B. Schreiben in Modul-Variablen oder API-Aufrufe.
Wenn der Compiler solchen Code findet, überspring er normalerweise diese Komponente anstatt sie zu beschädigen. Das ist sicheres Verhalten, aber du verlierst die Optimierung.
7. Schrittweise Migration in großen Projekten
7.1. Opt-in-Modus
Bei großen Codebasen kannst du nur Komponenten kompilieren, die markiert sind:
// vite.config.js (Auszug)
plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
export function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
"use memo";
// ... diese Komponente wird kompiliert
}
Die Direktive "use memo" am Anfang des Komponentenkörpers teilt dem Compiler mit, diese Komponente zu verarbeiten. Andere Komponenten bleiben unverändert.
7.2. Eine Komponente temporär ausschließen
Wenn du global aktiviert hast aber eine Komponente Probleme verursacht, kannst du den Compiler temporär nur für diese Komponente deaktivieren:
export function LegacyChart(props: ChartProps) {
"use no memo";
// ... alter Code mit Bibliotheken, die Daten mutieren
}
Hinweis:
"use no memo"ist ein temporärer Ausweg. Du solltest den Grund dokumentieren und ein Task erstellen, um es zu beheben. Lass es nicht zur Standardlösung werden.
7.3. Was ist mit den alten useMemo und useCallback?
Der Compiler funktioniert mit bestehendem useMemo/useCallback-Code, daher musst du ihn nicht gleich löschen. Massenlöschungen können subtile Verhaltensänderungen bewirken, besonders wenn Werte als Dependencies von useEffect verwendet werden. Lösche schrittweise mit Tests. Schreibe neuen Code in reiner Form und lass den Compiler arbeiten.
8. Tests für Memoization-Verhalten schreiben
Du kannst überprüfen, dass Child-Komponenten nicht neu rendern, wenn nicht verwandter State sich ändert. Das folgende Beispiel nutzt Vitest und React Testing Library und läuft mit der gleichen Vite-Konfiguration, die den Compiler aktiviert hat:
npm install -D vitest @testing-library/react @testing-library/user-event jsdom
// OrdersPage.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
const renderSpy = vi.fn();
function OrderList({ orders }: { orders: string[] }) {
renderSpy();
return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}
function OrdersPage() {
const [clicks, setClicks] = useState(0);
const orders = ["Alice", "Bob"]; // kein useMemo verwendet
return (
<>
<button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
<OrderList orders={orders} />
</>
);
}
describe("OrdersPage mit React Compiler", () => {
it("OrderList wird nicht neu gerendert, wenn nur der Zähler sich ändert", async () => {
render(<OrdersPage />);
await userEvent.click(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveTextContent("Clicks: 2");
expect(renderSpy).toHaveBeenCalledTimes(1);
});
});
Erklärung:
ordersist ein Array-Literal, nicht mituseMemoumhüllt. Ohne Compiler wird jedes Mal ein neues Array erstellt undOrderListwird neu gerendert.- Mit dem Compiler werden sowohl das Array
ordersals auch das Element<OrderList />gecacht, da sie nicht vonclicksabhängen. renderSpywird nur einmal aufgerufen, beim ersten Render.
Erwartetes Ergebnis:
✓ OrdersPage mit React Compiler > OrderList wird nicht neu gerendert, wenn nur der Zähler sich ändert
Test Files 1 passed (1)
Wenn du den Compiler deaktivierst, schlägt die letzte Assertion fehl mit einem Wert von 3. Der Matcher toHaveTextContent benötigt @testing-library/jest-dom in deiner Setup-Datei.
Hinweis: Das Aufrufen eines Spys im Component-Body ist ein Nebeneffekt. Dies ist nur im Test akzeptabel, um Render-Aufrufe zu zählen. Verwende dies nicht in Production-Code.
9. Wann du es NICHT verwenden solltest
- Codebase verletzt Rules of React häufig: Der Compiler wird die meisten Komponenten überspringen oder noch schlimmer, versteckte Bugs werden sichtbar. Behebe zunächst die Lint-Warnungen.
- Mutations-basierte Bibliotheken: Manche State- oder Form-Bibliotheken ändern Objekte an Ort und Stelle und erwarten, dass Komponenten den neuen Wert bei jedem Render lesen. Für Komponenten, die diese nutzen, könnte
"use no memo"nötig sein. - Spezielle Build-Pipeline: Wenn du Babel nicht nutzt oder dein Toolchain keine Plugins unterstützt, wird die Integration aufwändig. Überprüfe die Unterstützung deines Bundlers vorher.
- Kleine Anwendungen mit wenig Interaktion: Der Vorteil könnte gegenüber erhöhter Build-Zeit und Debug-Komplexität nicht lohnenswert sein.
- Stabile Referenzen für Effects erforderlich: Der Compiler memoiert zur Optimierung, aber gibt keine Semantik-Garantie. Wenn Logik davon abhängt, dass ein Wert die gleiche Referenz behält, z.B. damit ein Effect nicht erneut läuft, behalte
useMemo/useCallbackexplizit, um die Absicht zu zeigen.
Außerdem unterscheidet sich der Code, der im Browser läuft, von dem Code, den du schreibst. Source Maps helfen, aber du solltest dich mit DevTools und dem Compiler-Playground vertraut machen, um die Ausgabe bei Bedarf zu sehen.
10. Fazit
React Compiler entfernt nicht useMemo und useCallback, sondern verwandelt sie von täglichen Werkzeugen in Auswege für Spezialfälle. Voraussetzung ist, dass Code die Rules of React einhält. Daher ist der erste Schritt immer, Linting zu aktivieren und Verletzungen zu beheben.
Empfohlener Ablauf: Aktiviere Linting, behebe Warnungen, aktiviere den Compiler im annotation-Modus für einige Komponenten, schreibe Tests für Render-Aufrufe und erweitere dann schrittweise. So erhältst du bessere Performance ohne manuelle Dependency-Arrays.