useState und useEffect: richtig verwenden

1. Überblick

useState und useEffect sind die zwei grundlegendsten Hooks von React, aber auch die häufigste Quelle von Bugs: Komponenten rendern sich unendlich, State wird nicht wie erwartet aktualisiert, Effects laufen wiederholt ab, obwohl nicht nötig. Dieser Artikel behandelt die tatsächliche Funktionsweise dieser zwei Hooks, richtige Patterns und häufige Fehler, die du vermeiden solltest. Nach dem Lesen kannst du selbstbewusster die Frage beantworten „Brauche ich hier wirklich useEffect?” — eine Frage, die viele React-Entwickler immer noch falsch beantworten.

2. useState – Grundlagen

useState gibt ein Paar [value, setter] zurück. Jedes Mal, wenn man den setter aufruft, plant React ein Neurendern der Komponente mit dem neuen Wert ein.

import { useState } from "react";

function CustomerCounter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Kundenanzahl: {count}</p>
      <button onClick={() => setCount(count + 1)}>Kunde hinzufügen</button>
    </div>
  );
}
  • useState(0) initialisiert count = 0 nur beim ersten Render.
  • setCount(count + 1) erstellt einen neuen Render mit count erhöht um 1.
  • count in der Closure des aktuellen Renders ist unveränderbar — es ändert sich nicht automatisch zwischen zwei Renders.

Hinweis: Das Aufrufen von setCount ändert den Wert von count nicht sofort im selben Render. React fasst die Updates zusammen und rendert danach neu.

3. useState: State-Updates basierend auf altem State

Wenn du den setter mehrfach im selben Event Handler basierend auf count aufrufen, wird das Ergebnis falsch.

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // Ergebnis: count erhöht sich nur um 1, nicht um 3
}

Verwende functional updates, um den aktuellen Wert zu erhalten:

function handleTripleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  // Ergebnis: count erhöht sich um genau 3
}
  • Wenn du eine Funktion an den setter übergibst, stellt React sicher, dass prev immer der neueste State-Wert ist, nicht der in der alten Closure erfasste Wert.
  • Regel: Wenn der neue State vom alten State abhängt, verwende immer functional updates.

4. useState: Lazy Initialization

Wenn der Initialisierungswert teuer zu berechnen ist, rufe die Funktion nicht direkt auf — übergebe eine Funktion an useState, damit sie nur einmal ausgeführt wird.

function parseInitialOrders(raw) {
  console.log("Analysiere Order-Daten...");
  return JSON.parse(raw);
}

function OrderList({ rawOrders }) {
  // FALSCH: parseInitialOrders läuft bei jedem Render aus
  // const [orders, setOrders] = useState(parseInitialOrders(rawOrders));

  // RICHTIG: läuft nur beim ersten Render
  const [orders, setOrders] = useState(() => parseInitialOrders(rawOrders));

  return <p>Anzahl Orders: {orders.length}</p>;
}
  • Die falsche Variante ruft parseInitialOrders bei jedem Render auf, obwohl React das Ergebnis nur beim ersten Mal benötigt.
  • Die richtige Variante übergibt eine Funktionsreferenz; React ruft sie nur beim ersten Mount auf.

5. useEffect – Grundlagen

useEffect führt einen Side Effect nach dem Rendern der Komponente aus (nachdem das DOM aktualisiert wurde).

import { useEffect, useState } from "react";

function PageTitleUpdater({ orderCount }) {
  useEffect(() => {
    document.title = `Du hast ${orderCount} Orders`;
  });

  return <p>Aktuelle Orders: {orderCount}</p>;
}
  • Ohne Dependency Array (der zweite Parameter) läuft dieser Effect nach jedem Render — beim Mount und bei Updates.
  • document.title ist ein typischer Side Effect: Es interagiert mit der Außenwelt außerhalb von React (DOM API), nicht nur mit der Berechnung von Renderwerten.

6. useEffect: Dependency Array

Das Dependency Array bestimmt, wann der Effect läuft. Es gibt 3 Fälle, die du kennen solltest.

6.1. Kein Dependency Array

useEffect(() => {
  console.log("Läuft nach JEDEM Render");
});

6.2. Leeres Dependency Array []

useEffect(() => {
  console.log("Läuft NUR EINMAL nach dem Mount");
}, []);

6.3. Dependency Array mit Werten

function CustomerProfile({ customerId }) {
  const [customer, setCustomer] = useState(null);

  useEffect(() => {
    fetchCustomer(customerId).then(setCustomer);
  }, [customerId]);

  return <p>{customer?.name ?? "Laden..."}</p>;
}
  • Der Effect läuft nur neu, wenn sich customerId zwischen zwei Renders ändert (Vergleich mit Object.is).
  • Wenn du customerId nicht im Array aufführst, wird der Effect den alten customerId-Wert verwenden (stale closure) — es wird der falsche Kunde abgerufen, wenn sich die Prop ändert.

Hinweis: Das ESLint-Plugin eslint-plugin-react-hooks mit der Regel exhaustive-deps warnt dich, wenn du eine Dependency vergisst. Deaktiviere diese Regel nicht, es sei denn, du verstehst die Konsequenzen wirklich.

7. useEffect: Cleanup-Funktion

Wenn der Effect ein Subscription, Timer oder Event Listener erstellt, musst du eine Cleanup-Funktion zurückgeben, um ein Leak zu vermeiden.

function OrderStatusListener({ orderId }) {
  const [status, setStatus] = useState("pending");

  useEffect(() => {
    const subscription = subscribeToOrderStatus(orderId, setStatus);

    return () => {
      subscription.unsubscribe();
    };
  }, [orderId]);

  return <p>Status: {status}</p>;
}
  • Die Cleanup-Funktion läuft bevor der Effect neu läuft (wenn sich orderId ändert) und wenn die Komponente unmountet wird.
  • Ohne Cleanup wird jede orderId-Änderung ein weiteres neues Subscription erstellen — am Ende wird setStatus von vielen alten Subscriptions aufgerufen, was zu falschen Updates oder Memory Leaks führt.
Ausführungsreihenfolge, wenn orderId sich von 1 zu 2 ändert:
1. Cleanup des Effects mit orderId = 1
2. Neuer Effect mit orderId = 2

8. Häufige Fehler

8.1. Infinite Loop durch fehlendes korrektes Dependency

function BadExample() {
  const [data, setData] = useState({ count: 0 });

  useEffect(() => {
    // neues Objekt bei jedem Render -> Effect sieht immer "Änderung", wenn data in deps aufgeführt ist
    setData({ count: data.count + 1 }); // verursacht Re-Render -> Effect läuft wieder -> unendlich
  }, [data]);

  return <p>{data.count}</p>;
}

Behebe das mit functional updates und trenne Stoppbedingungen, oder entferne useEffect komplett, wenn diese Logik keine echten Side Effects benötigt.

8.2. Stale Closure in setTimeout/setInterval

function Countdown() {
  const [seconds, setSeconds] = useState(10);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((prev) => prev - 1); // verwende functional updates, NICHT seconds direkt
    }, 1000);

    return () => clearInterval(id);
  }, []); // leere deps, weil wir seconds nicht direkt im Effect lesen

  return <p>{seconds}</p>;
}
  • Wenn du stattdessen setSeconds(seconds - 1) schreibst, wird die Closure immer das seconds = 10 aus der ersten Effect-Ausführung “merken” — der Countdown bleibt beim falschen Wert stecken.

8.3. useEffect zur Berechnung von abgeleiteten Werten verwenden

// FALSCH: redundant, verursacht zusätzliche Renders
function OrderSummaryBad({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price, 0));
  }, [items]);

  return <p>Gesamt: {total}</p>;
}

// RICHTIG: direkt während des Renders berechnen
function OrderSummaryGood({ items }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Gesamt: {total}</p>;
}

Die “FALSCHE” Version verursacht zwei zusätzliche Renders: erst mit dem alten total, der Effect läuft, setTotal triggert einen zweiten Render mit dem korrekten Wert. Die “RICHTIGE” Version berechnet sofort während des Renders, braucht keinen Effect, keinen zusätzlichen State.

9. Wann man useEffect NICHT verwenden sollte

Hinweis: useEffect ist nur für die Synchronisierung mit Systemen außerhalb von React gedacht (DOM, APIs, Subscriptions, Timer). Wenn du einen Wert direkt aus vorhandenen Props/State berechnen kannst, verwende keinen Effect.

Drei Anzeichen, dass du es falsch machst:

  • Du verwendest den Effect, um einen abgeleiteten Wert basierend auf anderem State zu berechnen (verwende stattdessen eine normale Variable oder useMemo).
  • Du verwendest den Effect, um Benutzer-Events zu verarbeiten (verarbeite sie direkt im Handler onClick, onSubmit).
  • Du verwendest den Effect, um State zurückzusetzen, wenn sich eine Prop ändert — oft kannst du stattdessen die key-Prop verwenden, damit React die Komponente selbst neu mounted, statt einen Effect zu verwenden.
// Statt einen Effect zu verwenden, um das Formular zurückzusetzen, wenn sich customerId ändert:
// useEffect(() => { setForm(initialForm); }, [customerId]);

// Verwende die key-Prop, um die Komponente neu zu mounten, React setzt automatisch den ganzen internen State zurück:
<CustomerForm key={customerId} customerId={customerId} />

10. Hooks testen

Verwende Jest zusammen mit React Testing Library, um das tatsächliche Verhalten zu testen, statt Implementation Details.

npm install --save-dev @testing-library/react @testing-library/user-event jest
// CustomerCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomerCounter } from "./CustomerCounter";

test("erhöht count, wenn auf 'Kunde hinzufügen' geklickt wird", async () => {
  const user = userEvent.setup();
  render(<CustomerCounter />);

  expect(screen.getByText("Kundenanzahl: 0")).toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: "Kunde hinzufügen" }));

  expect(screen.getByText("Kundenanzahl: 1")).toBeInTheDocument();
});
  • Der Test ruft render auf und simuliert dann echte Klicks über userEvent, wie ein echter Benutzer.
  • Die Assertions basieren auf dem angezeigten Inhalt (getByText), nicht auf internem State — das ist die richtige Art, React zu testen: Verhalte testen, nicht Implementation.
PASS  ./CustomerCounter.test.jsx
  ✓ erhöht count, wenn auf 'Kunde hinzufügen' geklickt wird (45 ms)

11. Fazit

useState ist oberflächlich einfach, aber leicht fehlerhaft, wenn du vergisst, dass der Wert in der Closure pro Render “eingefroren” ist — verwende functional updates, wenn der neue State vom alten abhängt. useEffect ist mächtig, sollte aber nur zur Synchronisierung mit Systemen außerhalb von React verwendet werden; wenn ein Wert direkt während des Renders berechnet werden kann, wickle ihn nicht in einen Effect. Mit solidem Verständnis des Dependency Arrays und der Cleanup-Funktion vermeidest du die meisten Bugs, die mit diesen zwei Hooks verbunden sind.

reacthooksjavascriptfrontendtypescript