Zum Inhalt springen

7. React-Grundlagen

Zu Zen-Modus wechseln

React ist die Bibliothek, mit der in diesem Jahr Benutzeroberflächen entstehen, und zugleich die verbreitetste UI-Bibliothek überhaupt. Ihr Kern ist ein Denkwechsel: Statt die Oberfläche Schritt für Schritt zu verändern (“finde das Element, ändere seinen Text, hänge eine Klasse dazu”), beschreibt man, wie die Oberfläche für einen gegebenen Zustand auszusehen hat, und React kümmert sich um die Änderungen. Kurz: Die UI ist eine Funktion des Zustands.

Dieses Kapitel führt die Bausteine ein: Komponenten und JSX, Props und State, den Datenfluss, Formulare (mit der wichtigen Unterscheidung controlled gegen uncontrolled) und die Hook-Familie, mit der React Zustand und Seiteneffekte in Funktionen bringt. Drei Wochen sind dafür reserviert; React belohnt gründliches Fundament, weil die Next.js-Kapitel vollständig darauf aufbauen.

Ein Zähler, einmal imperativ mit direkter DOM-Manipulation, einmal deklarativ mit React:

// imperative: describe the STEPS
const label = document.querySelector("#count");
let count = 0;
button.addEventListener("click", () => {
count += 1;
label.textContent = String(count); // manual UI update, easy to forget
});
// declarative: describe the RESULT for a given state
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

Im imperativen Stil ist der Entwickler dafür verantwortlich, dass Zustand und Anzeige synchron bleiben, an jeder einzelnen Stelle, die den Zustand ändert. Vergessene Updates sind die Standardfehlerquelle wachsender Oberflächen. Im deklarativen Stil gibt es diese Fehlerklasse nicht: Die Komponente beschreibt die Anzeige für jeden Zustand, und React führt nach jeder Zustandsänderung die nötigen DOM-Änderungen selbst durch, effizient und vollständig.

Eine Komponente ist eine Funktion, die Oberfläche zurückgibt. Die Schreibweise dafür heißt JSX: HTML-artige Syntax mitten im TypeScript, in die mit geschweiften Klammern beliebige Ausdrücke eingebettet werden.

interface JobCardProps {
filename: string;
progress: number; // 0 to 1
}
function JobCard({ filename, progress }: JobCardProps) {
return (
<section className="job-card">
<h3>{filename}</h3>
<p>{Math.round(progress * 100)} % rendered</p>
</section>
);
}
// usage, like a custom HTML tag:
<JobCard filename="intro.mp4" progress={0.62} />

Die JSX-Spielregeln:

  • Komponentennamen beginnen groß (JobCard); kleingeschriebene Tags hält React für HTML.
  • {...} bettet Ausdrücke ein: Variablen, Rechnungen, Funktionsaufrufe. Anweisungen (if, for) gehören nicht hinein, wohl aber deren Ausdrucks-Formen (dazu gleich).
  • Statt class heißt das Attribut className (class ist in JavaScript reserviert).
  • Eine Komponente gibt ein Wurzelelement zurück; wer mehrere Geschwister braucht, umschließt sie mit einem leeren Fragment <>...</>.

Props sind die Parameter einer Komponente: Der Aufrufer übergibt sie als Attribute, die Komponente empfängt sie als Objekt (per Destructuring entpackt) und ein Interface dokumentiert den Vertrag. Props sind schreibgeschützt; eine Komponente verändert nie ihre eigenen Props, sie liest sie nur. Und wie bei Funktionen gilt das Zerlegungsprinzip: Sobald eine Komponente mehrere Aufgaben hat oder unübersichtlich wird, wird sie in kleinere Komponenten zerlegt, aus denen sich die Seite zusammensetzt.

Weil JSX Ausdrücke einbettet, sind Wiederholung und Fallunterscheidung gewöhnliches JavaScript:

function JobList({ jobs }: { jobs: Job[] }) {
if (jobs.length === 0) {
return <p>No jobs yet.</p>;
}
return (
<ul>
{jobs.map((job) => (
<li key={job.id}>
{job.filename} {job.state === "failed" && <strong>failed!</strong>}
</li>
))}
</ul>
);
}
  • Listen entstehen mit map: aus jedem Datenelement ein Stück JSX. Jedes Listenelement braucht ein key-Attribut mit einem stabilen, eindeutigen Wert (die ID der Daten, nicht der Array-Index): Daran erkennt React beim nächsten Rendern, welches Element welches war. Falsche Keys führen zu subtilen Anzeigefehlern, etwa Eingabefeldern, deren Inhalt beim Sortieren am falschen Eintrag klebt.
  • Bedingungen schreiben sich als frühes return, als ternärer Operator (bedingung ? <A /> : <B />) oder als && für “zeige nur wenn”. Je ein Beispiel:
// early return: the whole component depends on the condition
function JobBadge({ job }: { job: Job }) {
if (job.state === "queued") {
return <span>waiting...</span>;
}
return <span>{job.progress}%</span>;
}
// ternary operator: render either A or B
function JobStatus({ job }: { job: Job }) {
return job.state === "failed" ? <strong>failed!</strong> : <span>ok</span>;
}
// && : show only when the condition is true (otherwise nothing)
function JobWarning({ job }: { job: Job }) {
return <div>{job.state === "failed" && <strong>needs attention</strong>}</div>;
}

Props kommen von außen; State ist der Zustand, den eine Komponente selbst besitzt und ändert. Das Werkzeug dafür ist der erste Hook:

function JobFilter() {
const [query, setQuery] = useState(""); // state variable + setter
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="filter jobs"
/>
);
}

useState(startwert) liefert ein Paar: den aktuellen Wert und eine Setter-Funktion (per Array-Destructuring entpackt). Die Regeln, die man verinnerlichen muss:

  1. Zustand wird nie direkt geändert, nur über den Setter. query = "x" bewirkt nichts Sichtbares; erst setQuery("x") löst das Neu-Rendern aus, das die Anzeige aktualisiert.
  2. Objekte und Arrays werden ersetzt, nicht verändert. React erkennt Änderungen am Objektvergleich; list.push(item) bleibt unsichtbar. Richtig ist die Kopie mit Spread: setJobs([...jobs, newJob]) beziehungsweise setJob({ ...job, state: "done" }).
  3. Folgt der neue Wert aus dem alten, nimmt der Setter eine Funktion: setCount((current) => current + 1). Das ist die sichere Form gegen veraltete Zwischenstände, etwa bei zwei Erhöhungen im selben Ereignis.

Events binden sich als Props mit on-Präfix (onClick, onChange, onSubmit) und erhalten eine Funktion. Der Ablauf jeder Interaktion ist damit immer derselbe Kreislauf: Event feuert, Handler ruft Setter, React rendert die Komponente mit dem neuen Zustand neu.

Häufig ist der Zustand keine einzelne Zahl, sondern eine Liste. Genau hier greift Regel 2: Das Array wird nie an Ort und Stelle verändert (push, splice), sondern immer durch ein neues Array ersetzt, das der Setter bekommt. Die drei typischen Operationen als Beispiel:

function TagList() {
const [tags, setTags] = useState<string[]>(["draft", "urgent"]);
const addTag = (tag: string) => setTags([...tags, tag]); // append: spread + new item
const removeTag = (tag: string) => setTags(tags.filter((t) => t !== tag)); // remove: filter it out
const clearTags = () => setTags([]); // replace with an empty array
return (
<ul>
{tags.map((tag) => (
<li key={tag}>
{tag} <button onClick={() => removeTag(tag)}>x</button>
</li>
))}
</ul>
);
}

[...tags, tag], tags.filter(...) und [] haben eines gemeinsam: Sie erzeugen ein neues Array, statt das alte zu verändern. Nur so erkennt React die Änderung und rendert die Liste neu.

In React fließen Daten in eine Richtung: von Eltern- zu Kindkomponenten über Props. Will ein Kind etwas ändern, ruft es eine Funktion auf, die ihm die Eltern als Prop mitgegeben haben (“Events rauf”):

function JobDashboard() {
const [jobs, setJobs] = useState<Job[]>(initialJobs);
function cancelJob(id: number) {
setJobs(jobs.map((j) => (j.id === id ? { ...j, state: "failed" } : j)));
}
return <JobList jobs={jobs} onCancel={cancelJob} />;
}
function JobList({ jobs, onCancel }: { jobs: Job[]; onCancel: (id: number) => void }) {
return (
<ul>
{jobs.map((job) => (
<li key={job.id}>
{job.filename}
<button onClick={() => onCancel(job.id)}>cancel</button>
</li>
))}
</ul>
);
}

Damit stellt sich bei jedem Entwurf dieselbe Frage: Wo lebt welcher Zustand? Die Antwort folgt einer Regel: beim nächstgemeinsamen Vorfahren aller Komponenten, die ihn brauchen. Brauchen Filterfeld und Liste denselben Suchtext, gehört er in die gemeinsame Elternkomponente und wandert als Prop nach unten (lifting state up). Zustand, den nur eine Komponente braucht, bleibt bei ihr. Diese Entwurfsfrage entscheidet mehr über die Qualität einer React-Anwendung als jede Syntax.

Formulare sind das Haupteinfallstor für Benutzereingaben und in React ein eigenes Thema, weil es zwei legitime Ansätze gibt.

Beim kontrollierten Formularfeld ist React die einzige Quelle der Wahrheit: Der Feldwert kommt aus dem State (value), jede Eingabe läuft über onChange in den State zurück.

function NewJobForm({ onCreate }: { onCreate: (job: NewJob) => void }) {
const [filename, setFilename] = useState("");
const [preset, setPreset] = useState("web-1080p");
function handleSubmit(event: React.FormEvent) {
event.preventDefault(); // stop the browser's page reload
if (filename.trim() === "") return;
onCreate({ filename: filename.trim(), preset });
setFilename(""); // reset the field
}
return (
<form onSubmit={handleSubmit}>
<input value={filename} onChange={(e) => setFilename(e.target.value)} />
<select value={preset} onChange={(e) => setPreset(e.target.value)}>
<option value="web-1080p">Web 1080p</option>
<option value="web-4k">Web 4K</option>
</select>
<button type="submit" disabled={filename.trim() === ""}>Create job</button>
</form>
);
}

Der Preis sind ein State und ein Handler pro Feld. Der Gewinn ist Kontrolle in Echtzeit: Der Knopf ist deaktiviert, solange die Eingabe ungültig ist; eine Live-Validierung oder Zeichenzählung wäre eine Zeile; der State ist jederzeit der komplette, aktuelle Formularinhalt. Bei mehreren Feldern bündelt man den State gerne in einem Objekt (useState<NewJob>(...)) und aktualisiert per Spread.

Zwei Details: event.preventDefault() im Submit-Handler verhindert das Neuladen der Seite (das Standardverhalten von HTML-Formularen), und Checkboxen lesen sich über e.target.checked statt value.

Beim unkontrollierten Feld verwaltet der Browser den Wert selbst, wie in klassischem HTML; React liest ihn nur bei Bedarf aus, über eine Ref auf das DOM-Element:

function SearchForm({ onSearch }: { onSearch: (term: string) => void }) {
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
onSearch(inputRef.current?.value ?? "");
}
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue="" placeholder="search" />
<button type="submit">Search</button>
</form>
);
}

Statt value gibt es höchstens ein defaultValue (den Startwert); kein State, kein onChange, kein Re-Rendern pro Tastendruck.

Kriteriumcontrolleduncontrolled
Wert liegt inReact-StateDOM
Live-Validierung, abhängige Feldernatürlichumständlich
Feld zurücksetzen oder programmatisch füllentrivial (Setter)über die Ref
Aufwand pro FeldState + Handlereine Ref (oder gar nichts)
Re-Render bei jedem Tastendruckjanein

Faustregel: Controlled ist der Standard dieses Jahres, weil Validierung und UI-Reaktionen auf Eingaben ständig gebraucht werden. Uncontrolled ist die berechtigte Abkürzung für simple Fälle nach dem Muster “ein Feld, ein Submit, dazwischen egal”. Wichtig ist, sich pro Feld zu entscheiden: Ein value ohne onChange erzeugt ein eingefrorenes Feld (React warnt entsprechend), und das nachträgliche Umschalten zwischen beiden Modi quittiert React ebenfalls mit einer Warnung.

useState und useRef sind bereits aufgetreten; Zeit für die Systematik. Hooks sind Funktionen mit Präfix use, über die Funktionskomponenten Zustand und andere React-Mechanismen nutzen. Für alle gelten zwei Regeln, die React auch erzwingt: Hooks werden nur auf der obersten Ebene einer Komponente aufgerufen (nie in if, Schleifen oder verschachtelten Funktionen, denn React identifiziert Hooks über ihre Aufrufreihenfolge), und nur in Komponenten oder anderen Hooks.

Rendern soll rein sein: Props und State hinein, JSX heraus. Alles, was darüber hinausgeht (Daten laden, Timer starten, den Dokumenttitel setzen), ist ein Seiteneffekt und gehört in useEffect:

function JobMonitor({ jobId }: { jobId: number }) {
const [job, setJob] = useState<Job | null>(null);
useEffect(() => {
let canceled = false;
loadJob(jobId).then((loaded) => {
if (!canceled) setJob(loaded); // ignore stale responses
});
return () => { canceled = true; }; // cleanup on unmount / re-run
}, [jobId]); // re-run when jobId changes
if (job === null) return <p>loading ...</p>;
return <JobCard filename={job.filename} progress={job.progress} />;
}

Die drei Bestandteile: die Effekt-Funktion (läuft nach dem Rendern), das Dependency-Array (der Effekt läuft erneut, wenn sich einer der aufgezählten Werte ändert; [] bedeutet nur einmal nach dem ersten Rendern) und die optionale Cleanup-Funktion (räumt auf, bevor der Effekt erneut läuft oder die Komponente verschwindet; hier entschärft sie die Race Condition der überholten Antwort). Zwei Warnungen aus der Praxis: Ein fehlendes Dependency-Array plus Setter im Effekt ergibt eine Endlosschleife aus Rendern und Effekt. Und useEffect ist kein Werkzeug für abgeleitete Werte; was sich aus Props und State ausrechnen lässt (gefilterte Liste, Summe), wird direkt beim Rendern berechnet, ohne Effekt und ohne eigenen State.

useRef liefert ein Objekt mit einer beschreibbaren .current-Eigenschaft, das über alle Renderdurchgänge dasselbe bleibt. Zwei Einsatzfälle: der Zugriff auf DOM-Elemente (siehe uncontrolled Forms; auch für inputRef.current?.focus()) und das Aufbewahren veränderlicher Werte, die die Anzeige nicht betreffen, etwa eine Timer-ID oder das In-Flight-Flag gegen Doppelklicks. Merksatz zur Abgrenzung: State für alles Sichtbare, Ref für alles Unsichtbare.

Manche Werte braucht fast jede Komponente: das Farbschema, die angemeldete Benutzerin. Sie durch fünf Ebenen Props zu reichen (prop drilling) macht alle Zwischenkomponenten zu Durchreichern. Ein Context stellt einen Wert allen Nachkommen direkt zur Verfügung:

const ThemeContext = createContext<"light" | "dark">("light");
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar /> {/* anywhere below ... */}
</ThemeContext.Provider>
);
}
function ThemeBadge() {
const theme = useContext(ThemeContext); {/* ... read it directly */}
return <span>{theme}</span>;
}

Context ist für querschnittliche, selten wechselnde Werte gedacht; er ist kein Ersatz für gewöhnliche Props, und wer jeden Zustand global macht, verliert die Nachvollziehbarkeit des Datenflusses.

Jedes Mal, wenn React eine Komponente neu rendert, läuft ihre Funktion komplett von oben durch: Jede Berechnung darin wird erneut ausgeführt, jede im Rumpf definierte Funktion wird neu erzeugt. Meistens ist das gleichgültig, weil React schnell ist. Es wird erst zum Problem, wenn eine Berechnung wirklich teuer ist (etwa das Filtern und Sortieren tausender Einträge, das bei jedem Tastendruck erneut liefe) oder wenn eine im Rumpf erzeugte Funktion als Prop an eine Kindkomponente geht, die eigentlich nur bei echten Änderungen neu rendern sollte. Für diese beiden Fälle gibt es je ein Werkzeug, das ein Ergebnis über mehrere Renderings hinweg aufhebt (memoisiert):

  • useMemo ist für den ersten Fall: die teure Berechnung. Es führt die übergebene Funktion aus und merkt sich deren Ergebnis, bis sich eine Abhängigkeit ändert.
  • useCallback ist für den zweiten Fall: die stabile Funktion. Es merkt sich die übergebene Funktion selbst, sodass Kindkomponenten bei jedem Rendern dieselbe Referenz sehen.
// useMemo: skip the expensive computation unless `jobs` changed
const visibleJobs = useMemo(() => jobs.filter((j) => j.state === "failed"), [jobs]);
// useCallback: hand the child the same function reference across renders
const handleCancel = useCallback((id: number) => setJobs(remove(jobs, id)), [jobs]);

Beide bekommen also eine Funktion und ein Abhängigkeits-Array [deps]; der Unterschied ist nur, ob das Aufgehobene das Ergebnis der Funktion ist (useMemo) oder die Funktion selbst (useCallback). Technisch ist useCallback(fn, deps) sogar nichts anderes als die Kurzform für useMemo(() => fn, deps).

Wichtig bleibt die Reihenfolge: Beide sind Optimierungen, und der Standardfall ist, sie nicht zu brauchen. Man greift zu ihnen, wenn ein konkreter Engpass messbar ist, nicht vorsorglich; vorschnelle Memoisierung macht Code komplizierter statt schneller. Ins Repertoire gehören sie trotzdem, damit man sie in fremdem Code liest und für den Tag parat hat, an dem eine Filterung über tausende Einträge tatsächlich bremst.

Wiederkehrende Zustandslogik lässt sich in eigene Hooks auslagern: gewöhnliche Funktionen mit use-Präfix, die andere Hooks verwenden. Ein Klassiker, der Eingaben über einen Neustart hinweg erhält:

function useLocalStorage(key: string, initial: string) {
const [value, setValue] = useState(
() => localStorage.getItem(key) ?? initial);
useEffect(() => {
localStorage.setItem(key, value);
}, [key, value]);
return [value, setValue] as const;
}
// in any component, same shape as useState:
const [draft, setDraft] = useLocalStorage("comment-draft", "");

Die Komponente sieht nur die vertraute State-Schnittstelle; Speichern und Laden sind gekapselt. Eigene Hooks sind Reacts Antwort auf die Frage der Wiederverwendung von Logik (Komponenten wiederverwenden Oberfläche), und jede React-Codebasis von Rang hat einen Ordner voll davon.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Erklären: das deklarative Modell (“UI ist eine Funktion des Zustands”) gegen imperative DOM-Manipulation abgrenzen.
  • Anwenden: Komponenten mit typisierten Props schreiben, zerlegen und zusammensetzen; Listen mit korrekten Keys und Bedingungen rendern.
  • Anwenden: Zustand mit useState verwalten, immutabel aktualisieren und die Frage “wo lebt der Zustand” begründet beantworten.
  • Anwenden: Formulare als controlled Components mit Validierung bauen und begründen, wann uncontrolled mit useRef genügt.
  • Anwenden: Seiteneffekte mit useEffect (Abhängigkeiten, Cleanup) umsetzen und die Endlosschleifen-Falle vermeiden.
  • Nennen: die Einsatzgebiete von useRef, useContext, useMemo/useCallback und eigener Hooks nennen und in fremdem Code erkennen.
  • Aufgabe 11 - Komponenten-Baukasten
  • Aufgabe 12 - State-Training
  • Aufgabe 13 - Quiz reloaded