React 19 Compiler: Was er kann, was nicht, und wann du ihn aktivieren solltest

#react 19 compiler
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Der React 19 Compiler liegt bei den meisten Frontend-Teams seit seinem stabilen Release auf dem Stapel "sollten wir uns mal anschauen". Früher als React Forget bekannt, übernimmt er die Aufgabe, die useMemo und useCallback bisher von Hand erledigt haben: entscheiden, welche Werte und Komponenten zwischen Renders wiederverwendet werden können. Das Versprechen: Du löschst Boilerplate und bekommst umsonst eine schnellere App. Ein Teil davon stimmt, ein Teil ist Marketing. Dieser Beitrag geht durch, was der Compiler tut, wo er aufhört, wie du ihn einschaltest und wie du ihn in einer Codebasis ausrollst, die zu groß ist, um sie kaputtzumachen.

Was der Compiler ändert

React rendert eine Komponente neu, sobald ihr Parent neu rendert, es sei denn, etwas sagt ihm, dass es das nicht soll. Vor dem Compiler warst dieses Etwas du. Du hast Komponenten in React.memo gewickelt, Callbacks in useCallback, damit das memoisierte Kind eine stabile Referenz sieht, und teure Berechnungen in useMemo. Jeder dieser Wrapper kam mit einem Dependency-Array, das du von Hand korrekt halten musstest, und eine fehlende Dependency war die klassische Quelle für Stale-Closure-Bugs.

Der Compiler ist ein Babel-Plugin, das zur Build-Zeit läuft. Er analysiert jede Komponente und jeden Hook, ermittelt, welche Werte von welchen Eingaben abhängen, und schreibt den Code so um, dass jeder abgeleitete Wert gecacht und nur neu berechnet wird, wenn sich seine Eingaben ändern. In der Praxis sieht die Ausgabe aus wie eine Komponente mit einem Cache-Array aus Slots, die der Compiler füllt und prüft. Hat sich props.items nicht geändert, wird die daraus berechnete gefilterte Liste wiederverwendet. Schließt der onClick-Handler nur über stabile Werte, bekommt das Kind bei jedem Render dieselbe Funktionsreferenz, und ein memoisiertes Kind überspringt sein Render.

Der entscheidende Punkt: Der Compiler arbeitet auf Basis der React-Regeln. Er geht davon aus, dass Props und State unveränderlich sind, dass Hooks bedingungslos aufgerufen werden und dass Render-Funktionen pur sind. Code, der diese Regeln einhält, wird optimiert. Code, der das nicht tut, wird übersprungen, und genau diesen Teil lassen die meisten Artikel unter den Tisch fallen.

Was er nicht tut

Der Compiler ist absichtlich konservativ. Wenn er nicht beweisen kann, dass das Memoisieren eines Werts sicher ist, lässt er die Komponente in Ruhe, statt zu riskieren, ihr Verhalten zu ändern. Das heißt: Die Komponenten, die am ehesten übersprungen werden, sind genau die, die nachlässig geschrieben wurden, und das sind meist die, bei denen du dir Hilfe erhofft hast.

Mutation während des Renders ist der häufigste Grund, warum eine Komponente übersprungen wird. In ein Array pushen, das aus Props erzeugt wurde, oder ein Objekt verändern, nachdem es in JSX übergeben wurde, bricht die Annahmen des Compilers. Er optimiert diese Komponente nicht, und er warnt dich auch nicht, es sei denn, du hast die Lint-Regel installiert (dazu weiter unten mehr).

Externer veränderlicher State ist der zweite große Fall. Liest eine Komponente aus einer Variable auf Modulebene, einem globalen Singleton oder einem Store, der nicht über useSyncExternalStore oder einen Hook läuft, hat der Compiler keine Möglichkeit zu wissen, wann sich dieser Wert geändert hat. Er kann nichts memoisieren, was er nicht sehen kann.

Und dann gibt es die Grenze, die kein Tooling verschiebt. Der Compiler erkennt, dass ein Wert von props.items abhängt. Er erkennt nicht, dass für einen bestimmten Zweig nur die Länge der Liste eine Rolle spielt, oder dass zwei Objekte mit unterschiedlichen Referenzen in jeder Hinsicht gleich sind, die deine Geschäftslogik interessiert. Diese Art von Optimierung braucht Wissen über dein Datenmodell, und das hat kein Build-Tool. Wenn ein Parent seinem Kind bei jedem Render ein frisch gebautes Objekt übergibt, memoisiert der Compiler das Kind brav gegen dieses Objekt, und das Kind rendert trotzdem jedes Mal neu, weil sich die Eingabe per Referenz tatsächlich geändert hat. Das zu beheben bleibt eine Designentscheidung: die Objekterzeugung herausziehen, Primitive übergeben oder die Komponente umstrukturieren.

Schließlich ersetzt der Compiler keine Performance-Arbeit auf der Datenebene. Eine Komponente, die in 2 ms statt 6 ms rendert, hilft nicht, wenn die Seite 800 ms auf eine API wartet, die zu viel lädt. Das sehen wir regelmäßig in Performance-Audits: Teams aktivieren den Compiler, erwarten eine sichtbare Veränderung und bekommen nichts, weil die Render-Phase nie der Engpass war.

Aktivierung in Next.js und Vite

In Next.js ist der Compiler ein Config-Flag. In Next.js 15 liegt es unter experimental:

// next.config.js
const nextConfig = {
  experimental: {
    reactCompiler: true,
  },
};
module.exports = nextConfig;

In Next.js 16 ist die Option aus experimental herausgewandert und heißt schlicht reactCompiler: true. In beiden Fällen installierst du zuerst babel-plugin-react-compiler als Dev-Dependency. Beachte, dass die Aktivierung Babel für die betroffenen Dateien in den Build holt, was die Kompilierung im Vergleich zum reinen SWC-Pfad verlangsamt. Bei einer mittelgroßen App ist das beim Dev-Start und in der CI-Build-Zeit spürbar, also miss es, bevor du komplett ausrollst.

In Vite gibst du das Plugin über die Babel-Konfiguration des React-Plugins mit:

// vite.config.js
import react from '@vitejs/plugin-react';

export default {
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', {}]],
      },
    }),
  ],
};

Der Compiler zielt standardmäßig auf React 19. Mit dem Paket react-compiler-runtime und einer target-Option läuft er auch gegen React 17 und 18, was relevant ist, wenn du den Compiler haben willst, bevor das React-19-Upgrade selbst abgeschlossen ist. Den meisten Teams empfehlen wir, zuerst das Upgrade zu machen. Zwei große Änderungen auf einmal machen es schwer, eine Regression zuzuordnen.

Die Lint-Regel, auf die es ankommt

Der Compiler bringt eine ESLint-Integration mit, die die Code-Muster meldet, die er nicht optimieren will. Ursprünglich war das ein separates Paket, eslint-plugin-react-compiler. Inzwischen ist es in eslint-plugin-react-hooks aufgegangen, sodass eine aktuelle Version dieses Plugins mit der empfohlenen Konfiguration dir die Compiler-Diagnosen ohne zusätzliche Installation liefert.

Lass sie laufen, bevor du den Compiler aktivierst, nicht danach. Die Ausgabe ist eine Liste von Komponenten, die der Compiler überspringen wird, samt Grund: Mutation eines Werts, der im Render verwendet wird, ein bedingt aufgerufener Hook, ein Ref-Zugriff während des Renders und so weiter. In einer Codebasis, die nie auf die Rules of Hooks gelintet wurde, ist diese Liste lang. Das ist kein Grund aufzugeben. Jeder Eintrag war bereits ein latenter Bug oder eine Stelle, an der manuelle Memoization stillschweigend das Falsche getan hätte. Die schlimmsten Fälle zu beheben ist bei einer Codebasis mit ein paar hundert Komponenten meist eine Woche konzentrierte Arbeit, und es verbessert die App unabhängig davon, ob du den Compiler danach einschaltest.

Prüfen, was der Compiler getan hat

Du solltest dich nicht auf das Wort des Compilers verlassen. Die React-DevTools-Erweiterung markiert kompilierte Komponenten mit einem kleinen "Memo"-Badge im Komponentenbaum, sodass du auf einen Blick siehst, ob eine Komponente optimiert oder übersprungen wurde. Der Profiler-Tab ist der Ort für die eigentliche Prüfung: Nimm eine Interaktion auf, schau dir das Flame-Chart an und vergleiche die Zahl der gerenderten Komponenten vor und nach der Aktivierung des Compilers. Rendert eine Komponente immer noch bei jedem Tastendruck in einem unbeteiligten Eingabefeld, hat der Compiler sie entweder übersprungen oder ihre Eingaben ändern sich tatsächlich, und der Profiler zeigt dir, was von beidem zutrifft, indem er anzeigt, was sich geändert hat.

Ein nützlicher Workflow: Wähle ein paar Interaktionen, über die sich Nutzer beschweren, nimm sie mit dem Profiler auf dem aktuellen Build auf, speichere die Profile, aktiviere dann den Compiler für das betreffende Verzeichnis und nimm dieselben Interaktionen erneut auf. Vergleiche die Render-Zahlen und Commit-Dauern. Alles, was der Compiler verbessert hat, zeigt sich als weniger Renders. Alles, was sich nicht verbessert hat, deutet entweder auf eine übersprungene Komponente hin (Lint-Regel darauf laufen lassen) oder auf ein Problem, das der Compiler nie hätte lösen können.

Was du von den Zahlen erwarten kannst

Sei vorsichtig mit Behauptungen über Beschleunigungen. Wie groß der Gewinn ausfällt, hängt fast vollständig davon ab, wie viel manuelle Memoization die Codebasis bereits hatte und wie viele Komponenten unnötige Arbeit geleistet haben. Eine Codebasis, in der ein gewissenhaftes Team bereits alles in useMemo und useCallback gewickelt hat, wird zur Laufzeit sehr wenig Veränderung sehen, weil der Compiler das tut, was die Entwickler schon getan haben. Der Gewinn liegt dort darin, dass du den Großteil dieses manuellen Codes löschen und aufhören kannst, Dependency-Arrays zu pflegen.

Codebasen mit fast keiner manuellen Memoization und ein paar großen Listen oder Formularen sehen tendenziell die deutlichste Verbesserung, typischerweise bei Interaktionen, in denen ein Parent-Render früher in Dutzende Kinder kaskadierte, die jetzt aussteigen. Eingabelatenz in Formularen und Scroll-Performance in Datentabellen sind die Stellen, an denen der Unterschied am leichtesten zu spüren und mit dem Profiler am leichtesten zu messen ist.

Ein Effekt, der selten erwähnt wird: Memoization ist nicht kostenlos. Jeder gecachte Slot kostet Speicher und einen Vergleich bei jedem Render. Bei einem kleinen Komponentenbaum mit günstigen Renders kann der Compiler Code erzeugen, der marginal langsamer ist als die naive Version. Das ist selten messbar, aber ein Grund zu profilen statt anzunehmen.

Rollout in einer großen Anwendung

Der Compiler ist für schrittweise Einführung ausgelegt, und bei einer großen App solltest du das nutzen. Das Plugin akzeptiert eine sources-Option, eine Funktion, die einen Dateipfad erhält und zurückgibt, ob diese Datei kompiliert werden soll. In Next.js kannst du Compiler-Optionen über dasselbe Config-Flag übergeben, sodass die Beschränkung auf ein einzelnes Verzeichnis so aussieht:

const nextConfig = {
  experimental: {
    reactCompiler: {
      compilationMode: 'annotation',
    },
  },
};

Im Annotation-Modus fasst der Compiler nur Komponenten an, die eine "use memo"-Direktive am Anfang des Funktionskörpers tragen, was der sicherste Einstieg ist. Das Gegenteil gibt es auch: Im Standardmodus nimmt eine "use no memo"-Direktive eine Komponente aus. Das ist der Notausgang für eine Komponente, die sich nach der Kompilierung anders verhält, während du herausfindest, warum.

Eine sinnvolle Reihenfolge: Erst die Lint-Regel über die gesamte Codebasis laufen lassen und alles beheben, was sie in dem Bereich meldet, mit dem du anfangen willst, dann die Kompilierung für dieses Verzeichnis oder per Annotation aktivieren, dann mit dem Profiler und einem vollständigen Durchlauf deiner End-to-End-Tests prüfen, und erst dann den Umfang erweitern. Teams, die den globalen Schalter mit einem langen Lint-Rückstand umlegen, landen gern bei ein paar subtilen Verhaltensänderungen in Komponenten, die sich stillschweigend auf Mutation verlassen haben, und die sind hinterher elend aufzuspüren.

Achte besonders auf zwei Klassen von Code. Komponenten, die Refs während des Renders verändern oder ref.current lesen, um zu entscheiden, was gerendert wird, können sich unter dem Compiler anders verhalten. Und jede Komponente, die darauf angewiesen war, dass ein Re-Render als Nebeneffekt eines Parent-Updates passiert, statt weil sich ihre eigenen Eingaben geändert haben, hört auf, neu zu rendern. Dieser zweite Fall ist fast immer ein Bug im ursprünglichen Code, aber ein Bug, um den die App herumgearbeitet hat, und der Compiler entfernt den Workaround.

Solltest du ihn aktivieren?

Wenn du ein neues Next.js- oder Vite-Projekt auf React 19 startest: ja, vom ersten Tag an. Die Lint-Regel hält die Codebasis ehrlich, und du sammelst nie manuelle Memoization an, die du später wieder entfernen musst.

Wenn du eine bestehende App mit einem gesunden Lint-Setup und einem Team hast, das die Rules of Hooks bereits einhält, aktiviere ihn Verzeichnis für Verzeichnis über ein paar Sprints und miss jeden Schritt. Der Hauptgewinn ist Code, den du löschen darfst.

Wenn du eine bestehende App hast, die ohne viel Disziplin gewachsen ist, oder eine, die ein früheres Team oder ein KI-Assistent im Eiltempo erzeugt hat, mach zuerst den Lint-Durchlauf und betrachte den Compiler als spätere Belohnung. Die Lint-Ausgabe allein sagt dir viel über den Zustand der Codebasis. Wenn diese Liste lang genug ist, um abzuschrecken, lohnt sich ein Gespräch über Modernisierung, bevor sich ein Gespräch über den Compiler lohnt.

Wir helfen Teams bei genau diesen Entscheidungen im Rahmen unserer Webanwendungsentwicklung und unserer Code-Review-Arbeit. Wenn du eine zweite Meinung willst, ob der Compiler für deine Codebasis den Aufwand wert ist, oder jemanden, der den Profiler-Vergleich und die Lint-Triage mit dir durchgeht, schreib an hello@wolf-tech.io oder schau auf wolf-tech.io vorbei.