Refactoring von KI-generiertem React: Memory Leaks, Stale Closures und die TypeScript-Patterns, die sie verhindern

#React Memory Leak KI-generiert
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

KI-generierter React-Code hat einen konsistenten Fehlermodus: Er sieht vernünftig aus, besteht die CI und leckt dann in Produktion still Speicher. Die Muster sind nicht zufällig. Jeder KI-Coding-Assistent macht dieselbe Kategorie von Fehlern, weil diese Fehler in Unit-Tests unsichtbar sind und erst unter anhaltender realer Last sichtbar werden. Nach Audits von Dutzenden vibe-coded React-Frontends finden wir immer wieder dieselben sechs Übeltäter.

Dieser Beitrag benennt sie, zeigt, wie sie in Chrome-DevTools-Memory-Profilen erscheinen, und liefert die TypeScript-Lint-Konfiguration und Komponenten-Design-Patterns, die verhindern, dass die KI sie beim nächsten Generieren wieder einführt.


Warum KI-React-Code Speicher leckt

KI-Modelle sind auf Code trainiert, der auf Korrektheit reviewt wurde, nicht auf langlaufendes Lifecycle-Verhalten. Ein useEffect, das Daten holt und State setzt, funktioniert perfekt in einem Test, der eine Komponente mountet und sofort wieder unmountet. Es leckt erst, wenn ein Nutzer einen Tab dreißig Minuten offen lässt, mitten im Fetch wegnavigiert und einen setState-Aufruf auf einem unmounted Komponentenbaum auslöst.

Die KI kann diesen Fehler nicht beobachten. Sie generiert Code, der dem Muster entspricht, das sie am häufigsten gesehen hat - und das ist der Happy Path.

Das Ergebnis sind Frontends, die im Staging in Ordnung sind, am Demo-Tag in Ordnung sind und dann in Produktion schleichendes Heap-Wachstum produzieren, das dem Team drei Monate später auffällt, wenn sich die Support-Queue mit "die App fühlt sich träge an"-Meldungen füllt.


Muster 1: Fehlende useEffect-Cleanup-Funktionen

Das ist das häufigste Problem, das wir in KI-generiertem React-Code finden. Das Modell generiert ein useEffect, das eine Subscription startet, einen Fetch initiiert oder einen Timer aufsetzt - und lässt das Cleanup-Return weg.

Was die KI schreibt:

useEffect(() => {
  const subscription = someEventEmitter.on('update', handleUpdate);
}, [handleUpdate]);

Was tatsächlich passiert: Jeder Remount (Routenwechsel, React-Strict-Mode-Double-Invoke, bedingtes Rendering) fügt einen neuen Subscriber hinzu. Alte Subscriber melden sich nie ab. In einem Dashboard mit mehreren solchen Komponenten akkumulieren Event-Listener, bis der Tab einfriert.

Der Fix:

useEffect(() => {
  const subscription = someEventEmitter.on('update', handleUpdate);
  return () => subscription.off('update', handleUpdate);
}, [handleUpdate]);

Prävention - ESLint-Regel: eslint-plugin-react-hooks mit exhaustive-deps fängt die meisten Fälle, erzwingt aber nicht, dass ein Return-Wert existiert. Füge react/useEffect-cleanup-require aus eslint-plugin-react zu deiner Config hinzu und setze es auf error.


Muster 2: Window-Event-Listener ohne Removal

KI-Assistenten hängen häufig Listener an window innerhalb von Komponenten. Das Modell weiß, dass Side Effects in useEffect gehören, vergisst aber, dass window jede Komponente überlebt.

Das Leak:

useEffect(() => {
  window.addEventListener('resize', handleResize);
}, []);

Wenn diese Komponente unmountet, bleibt handleResize registriert. Wenn handleResize über Komponenten-State oder Props schließt - was fast immer der Fall ist -, hält es eine Referenz auf die gesamte Komponenten-Closure und verhindert Garbage Collection.

Fix und Lint: Dasselbe Cleanup-Muster greift auch hier. Auf der Lint-Seite kann no-restricted-syntax in ESLint so konfiguriert werden, dass es warnt, wenn window.addEventListener in einer Funktionskomponente ohne begleitendes removeEventListener in einem Return auftaucht. Das ist grob, aber als Catch-all effektiv.


Muster 3: Stale Closures über WebSocket- und Async-Handlern

Stale Closures sind das am schwersten im Code-Review zu erkennende Muster, weil es beim Bug darum geht, was die Funktion einfängt, nicht was sie tut. KI-Modelle generieren korrekt aussehenden Code, der zufällig über eine veraltete Referenz schließt.

Typisches Muster von einem KI-Assistenten:

const [messages, setMessages] = useState<Message[]>([]);

useEffect(() => {
  const ws = new WebSocket(endpoint);
  ws.onmessage = (event) => {
    // messages ist hier der Wert aus dem ersten Render
    setMessages([...messages, JSON.parse(event.data)]);
  };
  return () => ws.close();
}, []); // leere Deps - ws wird einmal erstellt, aber messages ist stale

Jede Nachricht nach der ersten hängt an das ursprüngliche leere Array an. Nach ein paar Minuten wird nur eine Nachricht angezeigt statt der wachsenden Liste.

Der TypeScript-konforme Fix nutzt die funktionale Updater-Form:

ws.onmessage = (event) => {
  setMessages(prev => [...prev, JSON.parse(event.data)]);
};

Das ist das korrekte Muster für jedes State-Update innerhalb eines stabilen Callbacks. Wir ergänzen eine eigene ESLint-Regel - oder einen Kommentar im System-Prompt, wenn du Cursor verwendest -, die die funktionale Updater-Form für alle setState-Aufrufe in useEffect und Event-Handlern erzwingt.


Muster 4: React.memo-Comparators, die Referenzen vergleichen

KI-Modelle kennen React.memo und fügen es oft Listen-Item-Komponenten als Performance-Optimierung hinzu. Was sie konsistent übersehen: Der standardmäßige Shallow-Equality-Check schlägt bei Objekt- und Array-Props fehl - und von der KI geschriebene Custom Comparators vergleichen oft das Falsche.

KI-generierter Comparator:

export const ListItem = React.memo(({ item, config }: Props) => {
  // ...
}, (prev, next) => prev.item === next.item && prev.config === next.config);

Wenn config ein im Parent-Render erstelltes Objektliteral ist (config={{ theme: 'dark' }}), gibt dieser Vergleich immer false zurück. Das Memo ist nutzlos, und schlimmer noch: Die Comparator-Funktion läuft bei jedem Parent-Render und erzeugt zusätzlichen Overhead.

Das TypeScript-Pattern, das das verhindert: Erzwinge stabile Objektidentität an der Aufrufstelle mit useMemo:

const config = useMemo(() => ({ theme: 'dark' }), []);

Und dokumentiere den Contract mit einer Notiz auf Typ-Ebene. Wir versehen Props, die referenziell stabil sein müssen, mit @stableRef-JSDoc-Tags - sie erzwingen nichts, machen die Anforderung aber für die KI sichtbar, wenn sie die Komponente als Kontext liest.


Muster 5: useCallback mit unvollständigen Dependency-Arrays

KI-Assistenten generieren useCallback, um Event-Handler zu memoizen, lassen aber häufig Dependencies weg, die innerhalb des Callbacks verwendet werden. TypeScript fängt das nicht - das Typsystem weiß nicht, welche Closure-Variablen für das Dependency-Array von useCallback relevant sind.

Der generierte Code:

const handleSubmit = useCallback(async () => {
  const result = await submitForm(formData); // formData nicht in den Deps
  setResult(result);
}, [submitForm]); // formData fehlt

handleSubmit verwendet immer das formData aus dem Render, in dem useCallback memoized hat - nicht das aktuelle. Die Formular-Submission sendet veraltete Daten.

Lint-Fix: react-hooks/exhaustive-deps fängt das, aber nur, wenn du es als error statt warn behandelst. Wir sehen die Regel in fast jeder KI-generierten ESLint-Config auf warn. Stufe sie auf error hoch. Das erzeugt anfangs etwas Rauschen, aber die Bugs, die es verhindert, sind die einmaligen Aufräumkosten wert.


Muster 6: Callback-Refs, die bei jedem Render neu erstellt werden

Das letzte Muster ist subtiler. KI-Modelle verwenden Inline-Funktionen als Refs, was funktioniert - aber bei jedem Render eine neue Funktion erstellt, was den Ref-Callback bei jedem Update zweimal auslöst (einmal mit null, einmal mit dem Element).

<div ref={(el) => { myRef.current = el; }} />

In einer Komponente, die häufig rendert, erzeugt das unnötige DOM-Zugriffsmuster. Gefährlicher: Wenn der Callback irgendein State-Update oder einen Effect auslöst, bekommst du kaskadierende Re-Renders.

Das stabile Callback-Ref-Pattern:

const setRef = useCallback((el: HTMLDivElement | null) => {
  myRef.current = el;
}, []); // stabil - keine Deps, wird also nie neu erstellt

<div ref={setRef} />

TypeScript kann das über einen eigenen Utility-Typ erzwingen, der Ref-Callbacks als useCallback-pflichtig markiert, wobei die Durchsetzung auf Typ-Ebene ein eigenes Lint-Plugin erfordert. Für die meisten Teams ist die einfachere Regel: Übergib nie eine Inline-Funktion an ref. Nimm sie in die Code-Review-Checkliste und in deinen Cursor/Copilot-System-Prompt auf.


So findest du diese Leaks in den Chrome DevTools

Wenn du ein React-Memory-Leak vermutest, ist der schnellste Diagnoseweg:

  1. DevTools öffnen → Memory-Tab
  2. Einen Heap-Snapshot als Baseline aufnehmen
  3. Mehrmals durch die verdächtige Komponente navigieren (mounten, interagieren, unmounten)
  4. Einen zweiten Snapshot aufnehmen
  5. Den Vergleich nach "Objects allocated between snapshots" filtern

Suche nach Closure-Einträgen mit großen Retained Sizes. Ein Klick darauf zeigt, welche Funktion über was geschlossen hat. Eine handleUpdate-Funktion mit einer Retained Size von mehreren Megabyte bedeutet, dass ein Event-Listener einen großen Komponentenbaum festhält.

Für Subscription-basierte Leaks zeigt das Listeners-Panel in den DevTools jeden aktiven Event-Listener auf window und DOM-Knoten - nützlich, um Muster 2 zu bestätigen.


ESLint so konfigurieren, dass es diese Muster an der Quelle fängt

Hier ist die minimale ESLint-Konfiguration, die verhindert, dass das meiste von oben durch KI-generierten Code eingeführt wird:

{
  "plugins": ["react-hooks", "react"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "error",
    "react/jsx-no-bind": ["error", {
      "allowArrowFunctions": false,
      "allowFunctions": false,
      "ignoreRefs": false
    }],
    "no-restricted-syntax": [
      "error",
      {
        "selector": "CallExpression[callee.object.name='window'][callee.property.name='addEventListener']",
        "message": "window.addEventListener in a component requires a cleanup - ensure removeEventListener is called in the useEffect return."
      }
    ]
  }
}

Die jsx-no-bind-Regel mit ignoreRefs: false fängt Muster 6. Der no-restricted-syntax-Selektor für window.addEventListener markiert Muster 2. Kombiniert mit exhaustive-deps auf error-Severity fängt das vier der sechs Muster bereits in der Lint-Phase ab, bevor irgendein Code das Review erreicht.


Das größere Bild: KI-Code braucht eine andere Art von Review

KI-Coding-Assistenten sind schnell darin, Code zu produzieren, der kompiliert. Sie sind schlecht darin, über Lifecycle, Referenzstabilität und die Lücke zwischen "läuft einmal korrekt" und "läuft dreißig Minuten unter realer Last korrekt" zu schlussfolgern.

Die praktische Konsequenz: KI-generierter React-Code braucht Reviewer, die gezielt auf Lifecycle-Korrektheit achten - nicht nur auf Logik-Korrektheit. Das ist eine andere Fähigkeit, und die Review-Checklisten der meisten Teams wurden für von Menschen geschriebenen Code verfasst, der diese Dinge selten aus Versehen falsch machte.

Wenn du mit einem vibe-coded React-Frontend arbeitest und nach längerer Nutzung träge Performance bemerkst, sind diese sechs Muster der richtige Startpunkt. Wir auditieren und sanieren React-Frontends regelmäßig im Rahmen unserer Services Code Quality Consulting und Custom Software Development.

Wenn du lieber zuerst über deine konkrete Situation sprechen möchtest, melde dich unter hello@wolf-tech.io oder besuche wolf-tech.io. Ein dreißigminütiges Gespräch reicht meist, um zu erkennen, ob dein Team das Leak mit einer Lint-Regel beheben kann oder ob das Komponenten-Design ein substanzielleres Refactoring braucht.