Zum Inhalt springen

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.

Ein Reducer ist eine reine Funktion, die den aktuellen State und eine Aktion entgegennimmt und den neuen State zurückgibt:

(currentState, action) => nextState

Rein 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.

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'.

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:

todoReducer.test.ts
const initial = [{ id: '1', title: 'Einkaufen', completed: false }]
const result = todoReducer(initial, { type: 'TOGGLE', id: '1' })
// result[0].completed === true ✓

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:

  1. Reducer-FunktiontodoReducer
  2. Initialwert[] (wird ignoriert, wenn Argument 3 vorhanden ist)
  3. Initialisierer — einmalig beim ersten Render aufgerufen, wie bei useState

Statt setTodos(neueTodos) rufst du dispatch({ type: 'TOGGLE', id: '...' }) auf.

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

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 switch

TypeScript 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.

useStateuseReducer
Geeignet fürEinzelne Werte, wenige OperationenMehrere zusammenhängende State-Übergänge
State-LogikVerteilt auf viele HandlerZentral im Reducer
TestbarkeitHandler schwer isoliert testbarReducer ist pure function — einfach testbar
LesbarkeitGut bei wenig LogikBesser 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.

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.