Startseite / Artikel

React-Komponenten müssen rein sein: Die Idee hinter StrictMode und dem Compiler

Warum React reine Komponenten erwartet, wie der doppelte Render von StrictMode Unreinheiten aufdeckt, wohin Nebeneffekte gehören, und warum sich der React Compiler auf Reinheit verlässt.

Verlag: REACTAPP.TOP9 Min. Lesezeit2023 Wörter

Quellen: React Components Must Be Pure — the Secret Behind StrictMode and the Compiler

Bis jetzt war die React-Philosophie: „Schreibe es auf diese Weise, und der Bildschirm erscheint“. Es gibt jedoch eine Regel, der React stillschweigend von jedem Komponenten erwartet, dass sie eingehalten wird – und sie erklärt zwei Dinge, die die meisten Neulinge verwirren: warum StrictMode Ihre Komponente zweimal aufruft und wie der React Compiler den Code so entschlossen optimieren kann. Diese Regel ist die Reinheit.

Was eine reine Funktion ist

Erinnern Sie sich an die Mathematik: y = f(x) = 2x gibt bei einer Eingabe von 3 den Wert 6 zurück. Das geschieht gestern, heute und bei der hundertsten Aufruf – und die Funktion greift niemals darauf zu, etwas in der Außenwelt außerhalb ihrer selbst zu ändern.

Eine solche Funktion ist rein, was bedeutet, dass zwei Bedingungen erfüllt sind.

  1. Die gleiche Eingabe liefert immer das gleiche Ergebnis.
  2. Es gibt keine Nebeneffekte, sodass nichts außerhalb ihres eigenen Bereichs verändert wird.

Der Code macht es klarer. double darunter ist rein, während addToTotal es nicht ist.

// ✅ pure function — same input, same output, and it touches nothing outside
function double(x: number): number {
  return x * 2;
}
double(3); // 6
double(3); // 6 — six no matter how many times you call it

// 🔴 impure function — it changes the outer variable `total` (a side effect)
let total = 0;
function addToTotal(x: number): number {
  total += x;     // side effect!
  return total;   // the result changes on every call
}
addToTotal(3); // 3
addToTotal(3); // 6 — same input, different result

Komponenten sollen ebenfalls rein sein

React geht davon aus, dass jede Komponente eine reine Funktion ist. Mit denselben Props und State muss sie jedes Mal denselben JSX-Ausdruck zurückgeben.

type Props = { name: string };

// ✅ pure — same name, always the same result
function Greeting({ name }: Props) {
  return <h1>Hello, {name}!</h1>;
}

Wenn man diesem Komponenten den Namen „Jane“ gibt, erhält man stets <h1>Hello, Jane!</h1>. React verlässt sich auf diese Vorhersehbarkeit, um den Bildschirm effizient aktualisieren zu können.

Die Regel brechen: Eingriff in den Außenbereich während der Renderung

Probleme entstehen, wenn eine Komponente während der Renderung etwas außerhalb von sich selbst ändert.

let count = 0; // a variable outside the component

// 🔴 not pure — changes an external variable while rendering
function Counter() {
  count = count + 1; // side effect!
  return <p>{count}</p>;
}

Counter erhöht die externe Variable count bei jeder Darstellung, wodurch die Ausgabe davon abhängt, wie oft er aufgerufen wird: 1 beim ersten Mal, 2 beim zweiten Mal. Beide Reinheitsbedingungen werden nicht erfüllt. React kann eine Komponente mehrmals oder in einer anderen Reihenfolge darstellen, wodurch solche Komponenten Fehler verursachen, die schwer vorherzusagen sind.

Probieren Sie es aus und sehen Sie, warum 2 angezeigt wird

Wenn Sie Counter ausführen, erscheint zuerst 2 auf dem Bildschirm und nicht 1, und dieser Wert steigt danach nicht weiter an. Zuerst verwirrend, doch das Verständnis dafür liefert eine gute Lektion über Reinheit.

Warum 2? Im Entwicklungsmodus ruft StrictMode beim Einbinden die Komponentenfunktion zweimal auf, sodass bei einem Einbinden Counter zweimal ausgeführt wird.

  • Erster Aufruf: count steigt von 0 auf 1 an und das Ergebnis ist <p>1</p>.
  • Zweiter Aufruf: count wechselt von 1 auf 2 und das Ergebnis ist <p>2</p>.

Der Bildschirm zeigt das zweite Ergebnis an. Wenn StrictMode ausgeschaltet ist, wird die Funktion nur einmal ausgeführt und man würde 1 sehen.

Warum hört es bei 2 auf anstatt weiter auf 3, 4 usw. zu gehen? Ein Komponente wird nur dann ausgeführt, wenn React sie neu rendernt. Diese hat keinen Zustand und ihr Elternteil wird nicht neu rendernt, daher wird sie beim ersten Einbinden (zweimal aufgrund von StrictMode) ausgeführt und bleibt danach inaktiv, wodurch count dort stecken bleibt.

Um zu beobachten, wie der Wert sich ändert, muss man einen erzwungenen Neurendern auslösen. Das nächste Beispiel macht das über einen Klick auf eine Schaltfläche. (useState wird später ausführlicher behandelt, daher kann man es vorerst einfach als ein Werkzeug betrachten, das den Bildschirm neu zeichnet, wenn man auf die Schaltfläche klickt.)

import { useState } from 'react';

let count = 0;

function Counter() {
  const [, setTick] = useState(0); // a device to trigger re-renders
  count = count + 1; // 🔴 changes an external variable on every render
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setTick((n) => n + 1)}>Re-render</button>
    </div>
  );
}

Jeder Klick führt zu einer erneuten Darstellung des Counter durch dessen eigene Zustandsänderung, und StrictMode ruft dies zweimal pro Klick ab, wodurch die Zahl um zwei zunimmt: 2, 4, 6 und so weiter. Ohne StrictMode würde sie nur schrittweise um eins steigen.

Eine Einschränkung zu dieser Demo: Warum wird der Zustand überhaupt innerhalb von Counter gespeichert? Wenn man ihn in den Elternteil verlegt und Counter als Kind ohne Props lässt, ändert das Drücken der Schaltfläche die angezeigte 2 nicht. Ein Komponente, die keine Eingaben entgegennimmt, hat ein Ergebnis, das niemals variieren kann, weshalb der React Compiler sie speichert und die erneute Darstellung überspringt. Um diese Abweichung zu beobachten, muss man die externe Variable innerhalb der Komponente anfassen, die tatsächlich neu rendern wird. Seltsamerweise ist dieses Verhalten selbst eine gute Veranschaulichung dafür, dass der Compiler von Reinheit ausgeht. Wie der Compiler Komponenten speichert, verdient eine eigene Erörterung.

Das ist der Kernpunkt: Ein reiner Component sollte immer das gleiche Ergebnis liefern, unabhängig davon, wie oft er aufgerufen wird – doch dieser Component hängt von der Anzahl der Aufrufe ab. StrictMode ruft ihn absichtlich zweimal auf, um diese Unreinheit vor Ihnen sichtbar zu machen.

Die Lösung besteht darin, den äußeren Einflüssen nichts anzuvertrauen. Während der Darstellung soll das Ergebnis ausschließlich aus den eigenen Props und State berechnet werden.

// ✅ pure — computed from input (props) alone
function Counter({ count }: { count: number }) {
  return <p>{count}</p>;
}

Sie können das ändern, was Sie während der Renderung erstellt haben

Reinheit bedeutet nicht, nichts zu ändern. Sie bedeutet, Dinge unberührt zu lassen, die bereits vor Beginn der Renderung existierten, wie z. B. externe Variablen, Props und State. Variablen oder Arrays, die Sie während der Renderung neu erstellen, können Sie frei modifizieren. Das ist lediglich eine Berechnung, kein Nebeneffekt.

function ProductList({ products }: { products: Product[] }) {
  // ✅ this array was just created in this render — handle it however you like
  const sorted = [...products].sort((a, b) => a.price - b.price);
  return (
    <ul>
      {sorted.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Wichtig ist die Kopie, die mit [...products] erstellt wird. Das Sortieren des ursprünglichen products-Arrays, das als Props übergeben wird, würde die Props verändern und die Regel brechen. Die Kopie wurde in dieser Render-Schleife erstellt, daher ist alles in Ordnung.

Erstes Rätsel: Warum StrictMode Dinge zweimal ausführt

In der Entwicklung ruft StrictMode die Komponentenfunktionen absichtlich zweimal auf, um Komponenten zu erkennen, die nicht rein sind. Es handelt sich dabei um denselben <StrictMode>-Wrapper, der um die Anwendung gelegt wurde, als das Projekt eingerichtet wurde.

// main.tsx — in development, the components inside this render twice
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Eine reine Komponente liefert bei beiden Ausführungen identische Ergebnisse, daher passiert nichts. Eine Komponente wie der obige Counter, die auf eine externe Variable zugreift, zeigt sofort Warnsignale: Die Ausgabe weicht ab oder ein Wert verdoppelt sich. StrictMode bringt diese Symptome bereits während der Entwicklung ans Licht, lange bevor sie zu unklaren Problemen werden, die nach Veröffentlichung der App schwierig zu beheben sind.

Die doppelte Ausführung ist also kein Fehler – es handelt sich dabei um eine Überprüfung des Zustands. Wenn eine zweimalige Ausführung keine Probleme verursacht, ist die Komponente rein. In einer Produktionsversion wird sie nur einmal ausgeführt.

Wo Nebeneffekte hingehören

Man wird sich eine berechtigte Frage stellen: Das Protokollieren, das Senden von Daten an einen Server und das Starten eines Timers sind offensichtlich notwendig – wohin gehören sie also? Irgendwo außerhalb der eigentlichen Darstellung.

  • Die Reaktion auf Benutzeraktionen wie Klicks und Eingabe gehört in Event-Handler.
  • Das Synchronisieren mit einem externen System nach dem Zeichnen des Bildschirms gehört zu den Effekten.

Nehmen wir an, Sie protokollieren einen Seitenaufruf direkt im Render-Body: Dies wird bei jeder Renderung ausgelöst und im StrictMode-Modus während der Entwicklung doppelt so oft. Verlegen Sie diesen Aufruf in einen Ereignishandler, dann erfolgt er erst dann, wenn tatsächlich jemand klickt.

// 🔴 side effect during rendering — fires on every render
function ProductPage({ id }: { id: number }) {
  logView(id); // a side effect in the render body — not allowed
  return <h1>Product {id}</h1>;
}

// ✅ in an event handler — runs only at the moment the user clicks
function BuyButton({ id }: { id: number }) {
  return <button onClick={() => logPurchase(id)}>Buy</button>;
}

Halten Sie den Render-Körper rein und verlagern Sie Nebeneffekte an ihre richtigen Stellen. Dieses Prinzip kommt immer wieder zur Sprache.

Zweites Rätsel: Warum der Compiler von Reinheit abhängt

Falls Sie den React Compiler aktiviert haben, erinnern Sie sich vielleicht daran, dass er die manuell geschriebenen Funktionen useMemo und useCallback übernimmt. Reinheit ist es, die das möglich macht.

Der Compiler arbeitet unter der Annahme, dass Komponenten rein sind. Da derselbe Eingang immer zum gleichen Ausgang führt, hält er es für sicher, vorherige Ergebnisse bei unveränderten Eingaben erneut zu verwenden, und fügt automatisch Memoisierung ein. Wenn eine Komponente während des Renderings Änderungen vornimmt, könnte die Wiederverwendung falsche Ergebnisse liefern; daher überspringt der Compiler die Optimierung für solche Komponenten, die diese Regel brechen. Die mit dem Compiler aktivierte ESLint-Regel zeigt an, welche Komponente dafür verantwortlich ist.

// 🔴 not pure → the compiler skips optimization (ESLint points at this component)
let renderCount = 0;
function RenderCounter() {
  renderCount++; // mutating an external variable during render — a violation
  return <p>{renderCount}</p>;
}

// ✅ pure → the compiler memoizes automatically (reuse the previous result when inputs match)
function Label({ text }: { text: string }) {
  return <p>{text}</p>;
}

function App() {
  return (
    <>
      <RenderCounter />
      <Label text="Pure Component" />
    </>
  );
}

export default App;

Eine rein geschriebene Komponente erscheint in den DevTools mit dem „Memo“-Schild, obwohl kein useMemo eingetippt wurde.

Kurz gesagt: Reinheit ist die Voraussetzung dafür, dass der Compiler Ihnen helfen kann. Schreiben Sie reine Komponenten, und die Leistungsverbesserungen folgen automatisch. Das Einzige, worüber man sich Sorgen machen muss, ist das Schreiben reinen Codes.

Die UI als Baum betrachten

Eine weitere Perspektive rundet das ab. Komponenten enthalten andere Komponenten: App enthält ProductList, die wiederum mehrere ProductCard-Elemente enthält. Diese Verkettung bildet natürlich einen Baum.

function App() {
  return <ProductList products={products} />;
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </ul>
  );
}

Da die Komponenten verschachtelt sind, ergibt dieser Code den folgenden Baum.

App
└─ ProductList
   ├─ ProductCard
   ├─ ProductCard
   └─ ProductCard

Je weiter man vordringt, desto wichtiger wird die Baumansicht. Daten in Form von Props fließen von oben nach unten durch diesen Baum, und bei dem später zu lernenden State wird die Entscheidung, wo er im Baum platziert werden soll, zum Kern des Designs. Behalten Sie das Bild „Bildschirm gleich Komponentenbaum“ im Hinterkopf – selbst komplexe UIs lassen sich strukturell durchdenken.

Zusammenfassung

  • React behandelt jede Komponente als reine Funktion: gleiche Props und State, gleicher JSX.
  • Während des Renderns sollte man nichts Externes ändern, einschließlich Variablen, Props und State. Werte, die im aktuellen Render neu erstellt werden, können problemlos geändert werden, zum Beispiel beim Sortieren einer Kopie.
  • Der doppelte Ausführungsmodus von StrictMode in der Entwicklung dient als Überprüfung, die unreine Komponenten aufdeckt. Eine reine Komponente bleibt unverändert.
  • Setzen Sie Nebeneffekte in Event-Handler oder Effects, niemals im Renderteil.
  • Reinheit ist es, die es dem React Compiler ermöglicht, automatisch zu optimieren – daher bringt das Schreiben reinen Codes bessere Leistung.
  • Die Benutzeroberfläche besteht aus einem Komponentenbaum, und die Daten fließen darin nach unten.

Bis jetzt ist der Bildschirm wie ein eingefrorenes Bild: Man kann ihn aus Komponenten zusammenstellen, Markup mit JSX schreiben, Daten über Props weiterleiten und dynamische Ansichten mit Bedingungen und Schleifen erstellen – doch das Drücken einer Schaltfläche bewirkt nichts. Der nächste Schritt besteht darin, den Bildschirm auf Benutzeraktionen reagieren zu lassen, und das beginnt mit der Handhabung von Ereignissen.

Die offiziellen React-Dokumentationsseiten zu der Aufrechterhaltung der Reinheit von Komponenten sowie zum Verständnis der Benutzeroberfläche als Baum behandeln denselben Stoff ausführlicher.