useReducer
Im letzten Kapitel sammelten sich viele Handler in der Komponente, die alle dasselbe strukturelle Muster hatten: neues Array berechnen, setzen, speichern. useReducer löst das, indem es alle State-Übergänge in einer zentralen, reinen Funktion definiert.
Das Reducer-Muster
Abschnitt betitelt „Das Reducer-Muster“Ein Reducer ist eine reine Funktion, die den aktuellen State und eine Aktion entgegennimmt und den neuen State zurückgibt:
(currentState, action) => nextStateRein bedeutet: keine Side Effects, kein Math.random(), kein Zugriff auf externe Variablen. Für dieselbe Eingabe kommt immer dieselbe Ausgabe. Das macht Reducer einfach isoliert testbar — ganz ohne React und ohne DOM.
Actions als Discriminated Union
Abschnitt betitelt „Actions als Discriminated Union“Mit TypeScript beschreibst du alle möglichen Aktionen als Discriminated Union — ein Typ, der über ein gemeinsames type-Feld unterscheidet:
type TodoAction = | { type: 'ADD'; title: string } | { type: 'TOGGLE'; id: string } | { type: 'DELETE'; id: string } | { type: 'CLEAR_COMPLETED' }Im switch-Block weiß TypeScript in jedem case automatisch, welche zusätzlichen Felder der Action-Typ hat — action.title ist nur in 'ADD' zugänglich, action.id nur in 'TOGGLE' und 'DELETE'.
Der Todo-Reducer
Abschnitt betitelt „Der Todo-Reducer“type Todo = { id: string; title: string; completed: boolean }
function todoReducer(todos: Todo[], action: TodoAction): Todo[] { switch (action.type) { case 'ADD': return [ ...todos, { id: crypto.randomUUID(), title: action.title, completed: false }, ] case 'TOGGLE': return todos.map(t => t.id === action.id ? { ...t, completed: !t.completed } : t ) case 'DELETE': return todos.filter(t => t.id !== action.id) case 'CLEAR_COMPLETED': return todos.filter(t => !t.completed) }}Der Reducer ist eine normale TypeScript-Funktion — kein React, kein JSX. Er kann in einer eigenen Datei (todoReducer.ts) leben und unabhängig getestet werden:
const initial = [{ id: '1', title: 'Einkaufen', completed: false }]
const result = todoReducer(initial, { type: 'TOGGLE', id: '1' })// result[0].completed === true ✓useReducer in der Komponente
Abschnitt betitelt „useReducer in der Komponente“useReducer ersetzt useState — statt einer Setter-Funktion bekommst du dispatch:
import { useReducer } from 'react'
const [todos, dispatch] = useReducer(todoReducer, [], () => { const gespeichert = localStorage.getItem('todos') return gespeichert ? JSON.parse(gespeichert) : []})Die drei Argumente:
- Reducer-Funktion —
todoReducer - Initialwert —
[](wird ignoriert, wenn Argument 3 vorhanden ist) - Initialisierer — einmalig beim ersten Render aufgerufen, wie bei
useState
Statt setTodos(neueTodos) rufst du dispatch({ type: 'TOGGLE', id: '...' }) auf.
localStorage synchronisieren
Abschnitt betitelt „localStorage synchronisieren“Da dispatch keinen Rückgabewert hat und der neue State erst nach dem nächsten Render verfügbar ist, berechnen wir ihn für das Speichern direkt selbst:
function dispatchUndSpeichern(action: TodoAction) { const neueTodos = todoReducer(todos, action) dispatch(action) localStorage.setItem('todos', JSON.stringify(neueTodos))}Die vollständige Komponente
Abschnitt betitelt „Die vollständige Komponente“import { useReducer, useState } from 'react'
type Todo = { id: string; title: string; completed: boolean }type TodoAction = | { type: 'ADD'; title: string } | { type: 'TOGGLE'; id: string } | { type: 'DELETE'; id: string } | { type: 'CLEAR_COMPLETED' }
function todoReducer(todos: Todo[], action: TodoAction): Todo[] { switch (action.type) { case 'ADD': return [...todos, { id: crypto.randomUUID(), title: action.title, completed: false }] case 'TOGGLE': return todos.map(t => t.id === action.id ? { ...t, completed: !t.completed } : t) case 'DELETE': return todos.filter(t => t.id !== action.id) case 'CLEAR_COMPLETED': return todos.filter(t => !t.completed) }}
export default function TodoApp() { const [todos, dispatch] = useReducer(todoReducer, [], () => { const gespeichert = localStorage.getItem('todos') return gespeichert ? JSON.parse(gespeichert) : [] }) const [eingabe, setEingabe] = useState('')
function dispatchUndSpeichern(action: TodoAction) { const neueTodos = todoReducer(todos, action) dispatch(action) localStorage.setItem('todos', JSON.stringify(neueTodos)) }
const offene = todos.filter(t => !t.completed)
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() if (!eingabe.trim()) return dispatchUndSpeichern({ type: 'ADD', title: eingabe.trim() }) 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={() => dispatchUndSpeichern({ type: 'TOGGLE', id: todo.id })} /> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}> {todo.title} </span> <button onClick={() => dispatchUndSpeichern({ type: 'DELETE', id: todo.id })}> × </button> </li> ))} </ul> {todos.some(t => t.completed) && ( <button onClick={() => dispatchUndSpeichern({ type: 'CLEAR_COMPLETED' })}> Erledigte löschen </button> )} </div> )}TypeScript: Exhaustive Switch
Abschnitt betitelt „TypeScript: Exhaustive Switch“Der größte TypeScript-Vorteil beim Reducer-Muster ist die Exhaustivitätsprüfung. Weil der Reducer eine explizite Rückgabe-Annotation hat (): Todo[]) und jeder case ein return enthält, prüft TypeScript, ob wirklich alle Fälle abgedeckt sind.
Füge eine neue Aktion hinzu, ohne sie im Reducer zu behandeln:
type TodoAction = | { type: 'ADD'; title: string } | { type: 'TOGGLE'; id: string } | { type: 'DELETE'; id: string } | { type: 'CLEAR_COMPLETED' } | { type: 'RENAME'; id: string; title: string } // neu — vergessen im switchTypeScript meldet sofort einen Fehler: Function lacks ending return statement and return type does not include ‘undefined’ — der switch kann jetzt einen Pfad haben, der nichts zurückgibt. Ohne die Rückgabe-Annotation würde der Fehler erst zur Laufzeit auftreten.
Das ist besonders wertvoll in Teams: Wenn du eine neue Aktion ergänzt, zwingt TypeScript dich, alle Stellen im Code zu finden, die damit umgehen müssen.
useState vs. useReducer
Abschnitt betitelt „useState vs. useReducer“useState | useReducer | |
|---|---|---|
| Geeignet für | Einzelne Werte, wenige Operationen | Mehrere zusammenhängende State-Übergänge |
| State-Logik | Verteilt auf viele Handler | Zentral im Reducer |
| Testbarkeit | Handler schwer isoliert testbar | Reducer ist pure function — einfach testbar |
| Lesbarkeit | Gut bei wenig Logik | Besser bei vielen benannten Aktionen |
Faustregel: Wenn du bei einem State-Update kurz überlegen musst, was genau gerade passiert — dann hat die Aktion einen Namen verdient. Sobald du anfängst, Namen zu vergeben, ist useReducer die natürlichere Wahl.
Ausblick
Abschnitt betitelt „Ausblick“todos und dispatch leben noch in der TodoApp-Komponente. Wenn du die App in kleinere Komponenten aufteilst — eine TodoListe, ein TodoEintrag, ein TodoFooter — musst du beide als Props durch den Baum weiterreichen. Das löst useContext, das nächste Kapitel.