Ich habe hunderte React Pull Requests in verschiedenen Teams reviewed. Dieselben Fehler tauchen immer wieder auf: useMemo um eine String-Konkatenation gewickelt, key={index} auf einer filterbaren Liste, useEffect-Ketten, die ein einzelner abgeleiteter Wert sein könnten. Das sind keine Junior-Fehler — Senior Engineers machen sie auch, weil Reacts mentales Modell an der Oberfläche trügerisch einfach wirkt.
Dieser Artikel behandelt die Interna, die für die tägliche Arbeit wichtig sind: wie der Fiber Tree funktioniert, was Reconciliation tatsächlich macht, und die Hook-Patterns, die die meisten Entwickler falsch anwenden. Keine Theorie um der Theorie willen — jeder Abschnitt ist eine Entscheidung, die du in deinem nächsten PR treffen wirst.
Der Fiber Tree: Was React tatsächlich baut
Wenn du JSX schreibst, schreibst du kein HTML. Du beschreibst einen Baum von JavaScript-Objekten. React nimmt diese Beschreibung und baut einen Fiber Tree — eine verkettete Liste von «Arbeitseinheiten», wobei jeder Knoten ein Element oder eine Komponente in deiner UI repräsentiert.
Jeder Fiber-Knoten hält Pointer zu seinem Kind, Geschwister und Eltern. Er hält die Props der Komponente, ihren Hooks-State (als verkettete Liste — deshalb ist die Hook-Reihenfolge wichtig) und eine Referenz zum tatsächlichen DOM-Knoten, den er repräsentiert. Entscheidend: React unterhält zu jeder Zeit zwei Fiber Trees: den «aktuellen» Baum, der auf dem Bildschirm ist, und einen «Work-in-Progress»-Baum, der während eines Renders aufgebaut wird.
Warum ist das wichtig? Weil wenn React «re-rendert», es das DOM noch nicht anfasst. Es baut einen neuen Work-in-Progress Fiber Tree auf, vergleicht ihn mit dem aktuellen Baum, berechnet den minimalen Satz an DOM-Änderungen und wendet sie dann in einem Batch an. Das ist Reconciliation.
// React doesn't work with the DOM directly.
// It maintains a Fiber tree — a linked list of "work units"
// Each Fiber node looks roughly like this:
interface FiberNode {
type: 'div' | 'span' | typeof MyComponent; // what to render
key: string | null;
props: Record<string, unknown>;
stateNode: HTMLElement | null; // the actual DOM node (or component instance)
child: FiberNode | null; // first child
sibling: FiberNode | null; // next sibling
return: FiberNode | null; // parent
alternate: FiberNode | null; // the "other" tree (current vs work-in-progress)
effectTag: 'PLACEMENT' | 'UPDATE' | 'DELETION';
memoizedState: unknown; // hooks live here, as a linked list
}
// When you write JSX, React builds this tree — NOT a DOM tree.
// <App> Fiber: App
// <Header /> Fiber: Header (child of App)
// <main> Fiber: main (sibling of Header)
// <Article /> Fiber: Article (child of main)
// </main>
// </App>
// Each Fiber holds its own hooks state (memoizedState),
// a pointer to its DOM node, and links to parent/child/sibling.Reconciliation: Wie React entscheidet, was sich ändert
Reconciliation ist Reacts Diffing-Algorithmus. Er durchläuft beide Fiber Trees (aktuell und Work-in-Progress) und wendet drei Regeln an, um zu entscheiden, was mit jedem Knoten passiert.
Regel 1: Wenn sich der Element-Typ geändert hat (div zu span, ComponentA zu ComponentB), reisst React den gesamten Subtree ab und baut ihn von Grund auf neu. Aller State geht verloren. Alle Kinder werden un- und remountet. Deshalb ist es teuer, eine Komponente in ein Conditional zu packen, das zwischen zwei verschiedenen Komponententypen wechselt — es ist kein Update, es ist eine vollständige Zerstörung und Rekonstruktion.
Regel 2: Wenn der Element-Typ gleich ist, hält React die Instanz am Leben und aktualisiert nur die geänderten Props. Das ist der schnelle Pfad. Ein Button, der className von "blue" zu "red" ändert, ist ein einziges DOM-Attribut-Update.
Regel 3: Für Listen von Kindern verwendet React Keys, um Elemente zwischen dem alten und neuen Baum zuzuordnen. Ohne Keys wird nach Index zugeordnet — was bedeutet, dass das Voranstellen eines Elements dazu führt, dass jedes bestehende Element mit den falschen Daten «aktualisiert» wird. Mit stabilen Keys identifiziert React, welche Elemente verschoben, hinzugefügt oder entfernt wurden, und wendet den minimalen Satz an Operationen an.
// Reconciliation = React's diffing algorithm
// It compares the OLD Fiber tree with the NEW one, node by node.
// Rule 1: Different type? Tear down and rebuild.
// Old: <div><Counter /></div>
// New: <span><Counter /></span>
// Result: entire <div> subtree destroyed, <span> subtree created from scratch.
// Counter loses ALL state — even if the component is identical.
// Rule 2: Same type? Update props, keep the instance.
// Old: <button className="blue" onClick={handleA}>Save</button>
// New: <button className="red" onClick={handleB}>Save</button>
// Result: React updates className and onClick on the existing DOM node.
// No unmount, no remount. Fast.
// Rule 3: Lists need keys for identity.
// Without keys, React matches by index:
// Old: [Alice, Bob, Charlie]
// New: [Zara, Alice, Bob, Charlie] // prepended
// React thinks: Alice→Zara (update), Bob→Alice (update), Charlie→Bob (update), +Charlie (insert)
// That's 4 operations instead of 1 insert.
// With keys:
// Old: [Alice(1), Bob(2), Charlie(3)]
// New: [Zara(0), Alice(1), Bob(2), Charlie(3)]
// React matches by key: Alice, Bob, Charlie are unchanged. Just insert Zara.
// 1 operation. That's why keys exist.Keys: Mehr als nur «Verwende eine eindeutige ID»
Jeder weiss, dass man key={item.id} auf Listen verwenden soll. Aber Keys haben einen tieferen Zweck, den die meisten Entwickler übersehen: Sie kontrollieren die Komponentenidentität im Fiber Tree.
Wenn sich ein Key ändert, behandelt React es als eine komplett andere Komponente — selbst wenn der Typ gleich ist. Die alte Instanz wird unmountet (aller State zurückgesetzt) und eine neue wird gemountet. Das ist unglaublich nützlich, um Komponenten-State ohne useEffect-Hacks zurückzusetzen.
Das klassische Beispiel: ein Formular, das sich zurücksetzen soll, wenn der Benutzer zu einem anderen Datensatz wechselt. Statt eines useEffect, der die ID beobachtet und State manuell zurücksetzt (was fragil ist und Zwischenzustände erzeugt), einfach das Formular mit der Datensatz-ID keyen. Wenn sich die ID ändert, macht React ein sauberes Unmount/Remount. Alle useState-Aufrufe liefern ihre Initialwerte. Alle useEffect-Cleanups laufen. Ein kompletter Neustart, ohne Code in der Komponente.
// DON'T: key={index} on a list that reorders, filters, or prepends
// This destroys the performance benefit AND causes state bugs
{items.map((item, i) => (
<TodoItem key={i} item={item} /> // index shifts when items move
))}
// DO: key={item.id} — a stable identity that follows the data
{items.map((item) => (
<TodoItem key={item.id} item={item} />
))}
// TRICK: Use key to FORCE a full remount when you want to reset state
// This is an intentional, legitimate use of reconciliation:
function EditForm({ userId }: { userId: string }) {
// Changing userId doesn't reset internal form state...
const [name, setName] = useState('');
// ...because React sees the same component type at the same position
}
// Fix: key the component on the identity that should trigger a reset
<EditForm key={userId} userId={userId} />
// Now when userId changes, React unmounts the old EditForm
// and mounts a fresh one — all state resets automatically.
// No useEffect cleanup, no stale state bugs.useMemo: Wann es wirklich hilft (und wann es Verschwendung ist)
useMemo ist der am meisten überstrapazierte Hook in React. Ich habe Codebases gesehen, in denen jeder berechnete Wert in useMemo gewickelt ist «für die Performance». Das ist kontraproduktiv — useMemo hat Overhead. Es speichert das vorherige Ergebnis und die Dependencies, vergleicht die Dependencies bei jedem Render und gibt den gecachten Wert zurück, wenn sich nichts geändert hat. Für billige Berechnungen kostet der Vergleich mehr als das Neuberechnen.
Die Regel ist einfach: useMemo ist für teure Berechnungen (grosse Arrays sortieren, komplexe Datentransformationen, Parsing) oder um Referenzen zu stabilisieren, die an React.memo-Kinder übergeben werden. Der zweite Teil ist entscheidend — useMemo ohne React.memo auf der empfangenden Komponente ist fast immer sinnlos, weil das Kind ohnehin re-rendert, wenn der Parent re-rendert.
Denke an useMemo + React.memo als Paar. useMemo stabilisiert die Referenz. React.memo auf dem Kind überspringt den Re-Render, wenn sich die Props nicht geändert haben. Entferne eines der Teile und die Optimierung funktioniert nicht.
// DON'T: useMemo everything "just in case"
// This is SLOWER than no memo for cheap operations:
const fullName = useMemo(() => first + ' ' + last, [first, last]);
// The comparison of [first, last] costs more than the concatenation.
// DON'T: useMemo on object literals passed to children
// unless the child is wrapped in React.memo
const style = useMemo(() => ({ color: 'red' }), []);
// If <Child> isn't React.memo'd, it re-renders anyway when its parent does.
// The stable reference achieves nothing.
// DO: useMemo for genuinely expensive computations
const sortedItems = useMemo(
() => items.toSorted((a, b) => a.score - b.score),
[items]
);
// Sorting 10,000 items on every render is real waste.
// DO: useMemo + React.memo as a pair
const MemoizedChild = React.memo(ExpensiveChart);
function Dashboard({ data }: { data: DataPoint[] }) {
const processed = useMemo(() => transformData(data), [data]);
// useMemo keeps the reference stable
// React.memo on the child skips re-render if props are the same
return <MemoizedChild data={processed} />;
// BOTH pieces are needed. useMemo alone does nothing visible.
// React.memo alone doesn't help if the parent creates new objects each render.
}useCallback: Dieselben Regeln gelten
useCallback ist buchstäblich useMemo(() => fn, deps) unter der Haube. Dieselben Regeln gelten: Es ist nur nützlich, wenn die stabile Referenz tatsächlich Arbeit downstream verhindert — was bedeutet, der Empfänger muss in React.memo gewickelt sein, oder die Funktion wird in einem useEffect-Dependency-Array verwendet.
Ich setze eine einfache Team-Regel durch: Verwende useCallback nicht, es sei denn, du kannst auf das React.memo oder useEffect zeigen, das davon profitiert. Wenn du das nicht kannst, fügst du Komplexität für null Performance-Gewinn hinzu.
// useCallback has the SAME rules as useMemo — it's literally
// useMemo(() => fn, deps) under the hood.
// DON'T: wrap every handler in useCallback
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
// Unless <Child> is React.memo, this is pure overhead.
// DO: useCallback when passing to a memoized child
const MemoButton = React.memo(Button);
function Toolbar({ onSave }: { onSave: () => void }) {
// If the parent re-renders, a new arrow function is created each time
// React.memo on Button would see a new prop and re-render anyway
// useCallback keeps the same function reference across renders
const handleSave = useCallback(() => onSave(), [onSave]);
return <MemoButton onClick={handleSave}>Save</MemoButton>;
}
// DO: useCallback for functions used in useEffect dependencies
function useAutoSave(data: Data) {
const save = useCallback(() => {
api.save(data);
}, [data]);
useEffect(() => {
const id = setInterval(save, 5000);
return () => clearInterval(id);
}, [save]); // stable reference prevents re-creating the interval
}Lazy State Initialization: Der Einzeiler-Fix
useState akzeptiert entweder einen Wert oder eine Funktion. Wenn du eine Funktion übergibst, ruft React sie nur beim initialen Mount auf — nicht bei jedem Render. Das ist wichtig, wenn der Initialwert teuer zu berechnen ist: Lesen aus localStorage, JSON parsen, Klasseninstanzen erstellen.
Ich sehe diesen Fehler ständig: useState(JSON.parse(localStorage.getItem(key)!)). Dieses Parse läuft bei jedem Render, aber nur das erste Ergebnis wird verwendet. Der Fix ist ein Zeichen: useState(() => JSON.parse(localStorage.getItem(key)!)). Die Arrow Function macht es lazy.
// DON'T: expensive computation runs on EVERY render
const [state, setState] = useState(parseJsonFromStorage(key));
// parseJsonFromStorage runs every render, but only the first result is used.
// DO: pass a function — React calls it only on mount
const [state, setState] = useState(() => parseJsonFromStorage(key));
// The () => wrapper makes it lazy. Runs once.
// Same pattern with useRef — but useRef has no lazy initializer:
// DON'T:
const worker = useRef(new HeavyWorker()); // created every render, discarded
// DO:
const workerRef = useRef<HeavyWorker | null>(null);
function getWorker() {
if (!workerRef.current) workerRef.current = new HeavyWorker();
return workerRef.current;
}
// Lazy singleton pattern. Created on first access, not on every render.useRef jenseits des DOM
useRef wird zu wenig genutzt. Die meisten Entwickler denken, es sei nur für den Zugriff auf DOM-Elemente. Aber seine wahre Stärke ist als veränderbarer Container, der über Renders hinweg bestehen bleibt, ohne Re-Renders auszulösen. Jeder Wert, der Renders überleben muss, aber keinen Re-Render verursachen soll, wenn er sich ändert, gehört in einen Ref.
Die Patterns unten sind die, die ich am meisten verwende: vorherige Werte tracken, stabile Callbacks erstellen, die immer die neueste Funktionsversion aufrufen (löst das Stale-Closure-Problem), und Intervals/Timeouts bauen, die nicht neu erstellt werden müssen, wenn sich ihr Callback ändert.
// useRef is NOT just for DOM elements.
// It's a mutable container that survives re-renders without causing them.
// Track previous value (no re-render, no useEffect)
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>();
useEffect(() => { ref.current = value; });
return ref.current;
}
// Stable callback that always calls the latest version
// without re-triggering effects that depend on it
function useStableCallback<T extends (...args: never[]) => unknown>(fn: T): T {
const ref = useRef(fn);
ref.current = fn; // always up to date, no dependency array needed
return useCallback(
((...args) => ref.current(...args)) as T,
[] // genuinely stable — never changes
);
}
// Avoid stale closures in intervals/timeouts
function useInterval(callback: () => void, ms: number) {
const callbackRef = useRef(callback);
callbackRef.current = callback; // always fresh
useEffect(() => {
const id = setInterval(() => callbackRef.current(), ms);
return () => clearInterval(id);
}, [ms]); // only re-creates when interval changes, not when callback does
}Die Regeln, die wirklich zählen
Nach Jahren der Führung von React-Teams sind das die Regeln, die ich durchsetze. Kein useMemo oder useCallback, es sei denn, du kannst das React.memo oder useEffect benennen, das davon profitiert. Verwende Key um Identität zu kontrollieren — setze State durch Key-Änderung zurück, nicht durch useEffect. Niemals Index als Key auf dynamischen Listen. Übergib Funktionen an useState für teure Initialwerte. Lege nicht-rendernde Werte in Refs, nicht in State. Leite Werte während des Renders ab, statt sie mit useEffect zu synchronisieren.
Die meisten React-Performance-Probleme werden nicht durch mehr Memoization gelöst — sondern durch weniger Rendern. Verschiebe State dorthin, wo er genutzt wird. Teile Komponenten auf, damit eine State-Änderung im Header nicht die gesamte Seite re-rendert. Verwende children-Props, damit Wrapper-Komponenten ihren Inhalt nicht re-rendern. Diese strukturellen Änderungen schlagen jede Menge useMemo.

