Zum Inhalt springen

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.

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[]>([])

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 inkonsistent
const [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 berechnet
const [todos, setTodos] = useState<Todo[]>([])
const offene = todos.filter(t => !t.completed)
const erledigt = todos.filter(t => t.completed)
const offeneAnzahl = offene.length

Diese Berechnungen laufen bei jedem Render neu — aber das ist günstig. Duplizierter State führt fast immer zu Bugs.

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.

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))
}
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>
)
}

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[] — nutzlos
useState<Todo[]>([]) // Typ: Todo[] — korrekt

JSON.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 hier
const gespeichert = localStorage.getItem('todos')
return gespeichert ? (JSON.parse(gespeichert) as Todo[]) : []

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.