useState – Vertiefung
Bisher haben wir useState für einzelne Werte und einfache Objekte verwendet. Diese Seite schaut tiefer: Wie modellierst du State für eine echte Anwendung? Was gehört in State — und was nicht?
Als roter Faden dient eine Todo-Liste, die wir durch das gesamte Kapitel weiterentwickeln.
Das Datenmodell
Abschnitt betitelt „Das Datenmodell“Jedes Todo ist ein Objekt mit drei Feldern:
type Todo = { id: string title: string completed: boolean}Der State ist ein Array davon:
const [todos, setTodos] = useState<Todo[]>([])Was gehört in State?
Abschnitt betitelt „Was gehört in State?“Die wichtigste Frage beim Modellieren von State: Was ist die minimale Information, aus der sich alles ableiten lässt?
Stell dir vor, du willst neben der Liste auch die Anzahl der offenen Todos anzeigen. Die falsche Intuition:
// ❌ Redundanter State — wird schnell inkonsistentconst [todos, setTodos] = useState<Todo[]>([])const [offeneAnzahl, setOffeneAnzahl] = useState(0)Wenn du ein Todo als erledigt markierst, musst du beide States synchron halten — eine häufige Fehlerquelle. Stattdessen: ableiten:
// ✅ Nur ein State — alles andere wird daraus berechnetconst [todos, setTodos] = useState<Todo[]>([])
const offene = todos.filter(t => !t.completed)const erledigt = todos.filter(t => t.completed)const offeneAnzahl = offene.lengthDiese Berechnungen laufen bei jedem Render neu — aber das ist günstig. Duplizierter State führt fast immer zu Bugs.
Initialisierung aus dem localStorage
Abschnitt betitelt „Initialisierung aus dem localStorage“Statt mit einem leeren Array zu starten, lesen wir beim ersten Render den gespeicherten Stand:
const [todos, setTodos] = useState<Todo[]>(() => { const gespeichert = localStorage.getItem('todos') return gespeichert ? JSON.parse(gespeichert) : []})Die Funktion als Initialwert wird nur einmal beim ersten Render aufgerufen — danach nie wieder. Das ist wichtig, weil localStorage.getItem und JSON.parse nicht bei jedem Re-Render laufen sollen.
Todos verwalten
Abschnitt betitelt „Todos verwalten“Alle Änderungen folgen dem Immutability-Prinzip. Wir kapseln setTodos und das Speichern in einer Hilfsfunktion:
function setTodosUndSpeichern(neueTodos: Todo[]) { setTodos(neueTodos) localStorage.setItem('todos', JSON.stringify(neueTodos))}Damit bleibt jeder Handler kurz:
function handleHinzufuegen(title: string) { setTodosUndSpeichern([ ...todos, { id: crypto.randomUUID(), title, completed: false }, ])}
function handleToggle(id: string) { setTodosUndSpeichern( todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t) )}
function handleLoeschen(id: string) { setTodosUndSpeichern(todos.filter(t => t.id !== id))}
function handleErledigteLoeschen() { setTodosUndSpeichern(todos.filter(t => !t.completed))}Eine vollständige Komponente
Abschnitt betitelt „Eine vollständige Komponente“import { useState } from 'react'
type Todo = { id: string title: string completed: boolean}
export default function TodoApp() { const [todos, setTodos] = useState<Todo[]>(() => { const gespeichert = localStorage.getItem('todos') return gespeichert ? JSON.parse(gespeichert) : [] }) const [eingabe, setEingabe] = useState('')
const offene = todos.filter(t => !t.completed) const erledigt = todos.filter(t => t.completed)
function setTodosUndSpeichern(neueTodos: Todo[]) { setTodos(neueTodos) localStorage.setItem('todos', JSON.stringify(neueTodos)) }
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() if (!eingabe.trim()) return setTodosUndSpeichern([ ...todos, { id: crypto.randomUUID(), title: eingabe.trim(), completed: false }, ]) setEingabe('') }
return ( <div> <h1>Todos ({offene.length} offen)</h1> <form onSubmit={handleSubmit}> <input value={eingabe} onChange={e => setEingabe(e.target.value)} placeholder="Neue Aufgabe..." /> <button type="submit">Hinzufügen</button> </form> <ul> {todos.map(todo => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={() => setTodosUndSpeichern( todos.map(t => t.id === todo.id ? { ...t, completed: !t.completed } : t) ) } /> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}> {todo.title} </span> <button onClick={() => setTodosUndSpeichern(todos.filter(t => t.id !== todo.id))}> × </button> </li> ))} </ul> <p>{offene.length} offen · {erledigt.length} erledigt</p> {erledigt.length > 0 && ( <button onClick={() => setTodosUndSpeichern(todos.filter(t => !t.completed))}> Erledigte löschen </button> )} </div> )}TypeScript
Abschnitt betitelt „TypeScript“Leere Arrays brauchen einen expliziten Typ.
TypeScript kann den Typ eines leeren Arrays nicht aus [] ableiten — ohne Annotation würde es never[] inferieren und jedes spätere push oder map ablehnen:
useState([]) // Typ: never[] — nutzlosuseState<Todo[]>([]) // Typ: Todo[] — korrektJSON.parse gibt any zurück.
Der localStorage speichert alles als String, und JSON.parse gibt any zurück — TypeScript prüft den Inhalt nicht. Wenn sich die Todo-Struktur ändert (z.B. ein Feld umbenannt wird), bemerkt TypeScript das nicht beim Lesen. Das ist ein bewusstes Risiko, das man bei localStorage-Persistenz eingeht. Für kritische Daten lohnt sich eine Validierungsbibliothek wie Zod.
// JSON.parse gibt `any` zurück — TypeScript vertraut dir hierconst gespeichert = localStorage.getItem('todos')return gespeichert ? (JSON.parse(gespeichert) as Todo[]) : []Das Problem: Wachsende Komplexität
Abschnitt betitelt „Das Problem: Wachsende Komplexität“Je mehr Operationen dazukommen, desto mehr Handler sammeln sich in der Komponente. Außerdem hat jede Operation einen anderen Namen — handleToggle, handleLoeschen, handleErledigteLoeschen — ohne eine gemeinsame Sprache dafür, was hier eigentlich passiert.
Im nächsten Kapitel schauen wir uns useReducer an — ein Muster, das alle State-Übergänge an einer Stelle bündelt, ihnen klare Namen gibt und sie testbar macht.