useState ist elegant für einfache Szenarien. Ein
Counter, ein Toggle, ein Text-Input – ein Wert, ein Setter, fertig. Aber
React-Anwendungen bleiben selten einfach. State wächst, verzweigt sich,
wird abhängig von anderen State-Teilen. Mehrere
useState-Aufrufe proliferieren, Update-Logik verteilt sich
über Event-Handler, und plötzlich ist unklar, wie eine Komponente in
einen bestimmten Zustand gekommen ist.
useReducer bietet eine Alternative. Keine neue
Funktionalität – alles, was useReducer kann, geht auch mit
useState. Aber die Organisation ist fundamental anders.
State-Management wird zentralisiert, Update-Logik explizit, und die
Architektur nähert sich dem an, was State-Management-Libraries wie Redux
seit Jahren praktizieren: Das Reducer-Pattern.
Betrachten wir ein Formular zur Benutzerregistrierung. Name, Email, Passwort, Passwort-Bestätigung. Dazu Validierungszustände für jedes Feld, Fehlermeldungen, ein Loading-Flag während der Submission, und ein genereller Formular-Zustand (unberührt, geändert, submitted).
Mit useState sieht das so aus:
function RegistrationForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [nameError, setNameError] = useState<string | null>(null);
const [emailError, setEmailError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleNameChange = (value: string) => {
setName(value);
if (value.length < 2) {
setNameError('Name zu kurz');
} else {
setNameError(null);
}
};
const handleSubmit = async () => {
setIsSubmitting(true);
setSubmitError(null);
// Validierung
let hasErrors = false;
if (name.length < 2) {
setNameError('Name zu kurz');
hasErrors = true;
}
if (!email.includes('@')) {
setEmailError('Ungültige Email');
hasErrors = true;
}
if (password !== passwordConfirm) {
setPasswordError('Passwörter stimmen nicht überein');
hasErrors = true;
}
if (hasErrors) {
setIsSubmitting(false);
return;
}
try {
await api.register({ name, email, password });
setIsSubmitted(true);
} catch (error) {
setSubmitError(error.message);
} finally {
setIsSubmitting(false);
}
};
// ... 200 weitere Zeilen
}
Elf separate State-Variablen. Update-Logik verteilt über verschiedene
Handler. Validierung teilweise inline, teilweise in
handleSubmit. Die Komponente ist über 200 Zeilen lang, und
es ist schwer nachzuvollziehen, wie alle State-Teile
zusammenspielen.
Neue Anforderungen – etwa ein “Dirty”-Flag, das anzeigt, ob das Formular geändert wurde – erfordern weitere State-Variablen und Updates an mehreren Stellen. Testing wird kompliziert, weil man die gesamte Komponente rendern muss, um die State-Logik zu testen.
Das Reducer-Pattern stammt aus der funktionalen Programmierung. Eine Reducer-Funktion nimmt zwei Dinge entgegen: den aktuellen State und eine Action. Sie gibt einen neuen State zurück. Das war’s.
function reducer(state, action) {
// Logik basierend auf action.type
return newState;
}
Die Funktion ist “pure” – keine Seiteneffekte, keine Mutationen, keine Überraschungen. Gleiche Inputs führen immer zu gleichem Output. Diese Vorhersagbarkeit macht Reducer testbar, debuggbar und verständlich.
Actions sind Objekte, die beschreiben, was passieren soll,
nicht wie. Konvention ist ein type-Feld (String),
das die Action identifiziert, und optional ein payload mit
Daten.
{ type: 'FIELD_CHANGED', payload: { field: 'name', value: 'Alice' } }
{ type: 'SUBMIT_STARTED' }
{ type: 'SUBMIT_SUCCESS' }
{ type: 'SUBMIT_FAILED', payload: { error: 'Network error' } }
Die Trennung zwischen Action-Beschreibung und State-Berechnung führt zu sauberer Architektur. Event-Handler dispatchen Actions. Der Reducer entscheidet, wie sich der State ändert. Die Logik ist zentralisiert und explizit.
useReducer nimmt einen Reducer und einen Initial-State
entgegen und gibt den aktuellen State sowie eine
dispatch-Funktion zurück.
const [state, dispatch] = useReducer(reducer, initialState);
Die dispatch-Funktion wird aufgerufen mit einer Action.
Der Reducer wird ausgeführt. Der State aktualisiert sich. Die Komponente
rendert neu.
dispatch({ type: 'INCREMENT' });
dispatch({ type: 'SET_VALUE', payload: 42 });
Das Registrierungsformular mit useReducer:
interface FormState {
fields: {
name: string;
email: string;
password: string;
passwordConfirm: string;
};
errors: {
name: string | null;
email: string | null;
password: string | null;
};
isSubmitting: boolean;
submitError: string | null;
isSubmitted: boolean;
}
type FormAction =
| { type: 'FIELD_CHANGED'; field: keyof FormState['fields']; value: string }
| { type: 'VALIDATE_FIELD'; field: keyof FormState['fields'] }
| { type: 'SUBMIT_STARTED' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_FAILED'; error: string };
const initialState: FormState = {
fields: { name: '', email: '', password: '', passwordConfirm: '' },
errors: { name: null, email: null, password: null },
isSubmitting: false,
submitError: null,
isSubmitted: false
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'FIELD_CHANGED':
return {
...state,
fields: {
...state.fields,
[action.field]: action.value
}
};
case 'VALIDATE_FIELD': {
const value = state.fields[action.field];
let error: string | null = null;
if (action.field === 'name' && value.length < 2) {
error = 'Name zu kurz';
} else if (action.field === 'email' && !value.includes('@')) {
error = 'Ungültige Email';
} else if (action.field === 'password' && value.length < 8) {
error = 'Passwort zu kurz';
}
return {
...state,
errors: {
...state.errors,
[action.field]: error
}
};
}
case 'SUBMIT_STARTED':
return {
...state,
isSubmitting: true,
submitError: null
};
case 'SUBMIT_SUCCESS':
return {
...state,
isSubmitting: false,
isSubmitted: true
};
case 'SUBMIT_FAILED':
return {
...state,
isSubmitting: false,
submitError: action.error
};
default:
return state;
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleFieldChange = (field: keyof FormState['fields']) => (value: string) => {
dispatch({ type: 'FIELD_CHANGED', field, value });
dispatch({ type: 'VALIDATE_FIELD', field });
};
const handleSubmit = async () => {
dispatch({ type: 'SUBMIT_STARTED' });
try {
await api.register(state.fields);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({ type: 'SUBMIT_FAILED', error: error.message });
}
};
return (
<form>
<input
value={state.fields.name}
onChange={e => handleFieldChange('name')(e.target.value)}
/>
{state.errors.name && <span>{state.errors.name}</span>}
{/* Weitere Felder */}
<button onClick={handleSubmit} disabled={state.isSubmitting}>
{state.isSubmitting ? 'Lädt...' : 'Registrieren'}
</button>
{state.submitError && <div>{state.submitError}</div>}
{state.isSubmitted && <div>Erfolgreich registriert!</div>}
</form>
);
}
Die gesamte State-Logik ist jetzt im Reducer. Die Komponente dispatched Actions und rendert basierend auf State. Die Trennung ist klar.
TypeScript und useReducer harmonieren perfekt.
Discriminated Unions für Actions ermöglichen vollständige Type
Safety.
type TodoAction =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'DELETE_TODO'; id: number }
| { type: 'SET_FILTER'; filter: 'all' | 'active' | 'completed' };
Im Reducer erkennt TypeScript automatisch den korrekten Payload-Typ
für jeden case:
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
// TypeScript weiß: action.text ist string
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.text, done: false }]
};
case 'TOGGLE_TODO':
// TypeScript weiß: action.id ist number
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
)
};
case 'DELETE_TODO':
// TypeScript weiß: action.id ist number
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.id)
};
case 'SET_FILTER':
// TypeScript weiß: action.filter ist 'all' | 'active' | 'completed'
return {
...state,
filter: action.filter
};
default:
// Exhaustiveness Check: TypeScript warnt, wenn ein Case fehlt
const _exhaustiveCheck: never = action;
return state;
}
}
Der default-Case mit never ist ein
TypeScript-Trick. Wenn alle Action-Types abgedeckt sind, ist
action vom Typ never (unmöglicher Typ). Wenn
ein Case fehlt, ist action nicht
never, und TypeScript meldet einen Fehler. Das garantiert,
dass wir keine Action-Types vergessen.
Ein oft übersehener Vorteil von useReducer liegt in der
dispatch-Funktion. Sie ist stabil – ihre Referenz ändert
sich zwischen Renderings nie. Das hat direkte
Performance-Implikationen.
function Parent() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<Child onAction={dispatch} />
);
}
const Child = React.memo(({ onAction }: Props) => {
// Child rendert nicht neu, wenn Parent neu rendert
// dispatch ist stabil
return <button onClick={() => onAction({ type: 'INCREMENT' })}>+</button>;
});
Mit useState müssten wir useCallback
verwenden:
function Parent() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []); // useCallback nötig für stabile Referenz
return <Child onIncrement={increment} />;
}
dispatch ist von Natur aus stabil. Kein
useCallback nötig. Das reduziert Boilerplate und
Komplexität.
useReducer eignet sich besonders für Zustandsmaschinen –
State mit klar definierten Übergängen. Ein Daten-Fetch-Prozess ist ein
Beispiel:
interface FetchState<T> {
status: 'idle' | 'loading' | 'success' | 'error';
data: T | null;
error: string | null;
}
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; data: T }
| { type: 'FETCH_ERROR'; error: string }
| { type: 'RESET' };
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading', data: null, error: null };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.data, error: null };
case 'FETCH_ERROR':
return { status: 'error', data: null, error: action.error };
case 'RESET':
return { status: 'idle', data: null, error: null };
default:
return state;
}
}
Die Zustände und Übergänge sind explizit. Es ist unmöglich, in einen
inkonsistenten Zustand zu geraten (z.B. status: 'success'
mit error: 'some error'). Der Reducer garantiert
Konsistenz.
Diese explizite Modellierung macht die Anwendung robuster und das Verhalten vorhersagbarer.
useReducer und Context kombinieren sich perfekt für
globales State-Management. Der Pattern ist als “Mini-Redux” bekannt und
bietet viele Vorteile von Redux ohne die Komplexität.
// State und Actions definieren
interface AppState {
user: User | null;
theme: 'light' | 'dark';
notifications: Notification[];
}
type AppAction =
| { type: 'USER_LOGIN'; user: User }
| { type: 'USER_LOGOUT' }
| { type: 'THEME_TOGGLE' }
| { type: 'NOTIFICATION_ADD'; notification: Notification }
| { type: 'NOTIFICATION_REMOVE'; id: string };
// Reducer
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'USER_LOGIN':
return { ...state, user: action.user };
case 'USER_LOGOUT':
return { ...state, user: null };
case 'THEME_TOGGLE':
return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' };
case 'NOTIFICATION_ADD':
return {
...state,
notifications: [...state.notifications, action.notification]
};
case 'NOTIFICATION_REMOVE':
return {
...state,
notifications: state.notifications.filter(n => n.id !== action.id)
};
default:
return state;
}
}
// Context erstellen
const AppStateContext = createContext<AppState | undefined>(undefined);
const AppDispatchContext = createContext<Dispatch<AppAction> | undefined>(undefined);
// Provider
function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, {
user: null,
theme: 'light',
notifications: []
});
return (
<AppStateContext.Provider value={state}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
);
}
// Custom Hooks
function useAppState() {
const context = useContext(AppStateContext);
if (!context) throw new Error('useAppState außerhalb von AppProvider');
return context;
}
function useAppDispatch() {
const context = useContext(AppDispatchContext);
if (!context) throw new Error('useAppDispatch außerhalb von AppProvider');
return context;
}
// In Komponenten
function UserDisplay() {
const { user } = useAppState();
const dispatch = useAppDispatch();
const handleLogout = () => {
dispatch({ type: 'USER_LOGOUT' });
};
return user ? (
<div>
{user.name}
<button onClick={handleLogout}>Logout</button>
</div>
) : (
<div>Nicht eingeloggt</div>
);
}
Zwei separate Contexts – einer für State, einer für
dispatch – vermeiden unnötige Re-Renderings. Komponenten,
die nur Actions dispatchen, konsumieren nur
AppDispatchContext und rendern nicht neu, wenn sich State
ändert.
| Aspekt | Context + useState | Context + useReducer |
|---|---|---|
| State-Updates | Mehrere Setter-Funktionen | Eine dispatch-Funktion |
| Funktions-Stabilität | useCallback nötig | dispatch ist stabil |
| Komplexe Updates | Unübersichtlich | Zentralisiert im Reducer |
| Testbarkeit | Schwierig | Reducer isoliert testbar |
| Debugging | Verteilt | Actions loggen |
Die Wahl zwischen useState und useReducer
ist keine binäre Entscheidung. Beide haben ihre Berechtigung.
useState ist ideal für: - Einzelne, unabhängige Werte (Flags, Text-Inputs, Zahlen) - Einfache Toggle- oder Inkrement-Logik - State ohne komplexe Abhängigkeiten - Kleine Komponenten mit wenig State
// Perfekt für useState
const [isOpen, setIsOpen] = useState(false);
const [count, setCount] = useState(0);
const [name, setName] = useState('');
useReducer ist ideal für: - Mehrere zusammenhängende State-Werte - Komplexe Update-Logik - State-Maschinen mit definierten Übergängen - Wenn viele verschiedene Actions denselben State modifizieren
// Besser mit useReducer
const [formState, dispatch] = useReducer(formReducer, initialFormState);
const [fetchState, dispatch] = useReducer(fetchReducer, initialFetchState);
Als Faustregel: Wenn du dich dabei ertappst, mehrere
useState-Hooks zu haben, die oft zusammen aktualisiert
werden, oder wenn Update-Logik sich über verschiedene Event-Handler
wiederholt, ist useReducer wahrscheinlich die bessere
Wahl.
Ein großer Vorteil von useReducer liegt in der
Testbarkeit. Reducer sind pure Functions – kein React, kein DOM, keine
Seiteneffekte. Tests sind einfach und schnell.
describe('todoReducer', () => {
it('should add todo', () => {
const state: TodoState = { todos: [] };
const action: TodoAction = { type: 'ADD_TODO', text: 'Test' };
const newState = todoReducer(state, action);
expect(newState.todos).toHaveLength(1);
expect(newState.todos[0].text).toBe('Test');
});
it('should toggle todo', () => {
const state: TodoState = {
todos: [{ id: 1, text: 'Test', done: false }]
};
const action: TodoAction = { type: 'TOGGLE_TODO', id: 1 };
const newState = todoReducer(state, action);
expect(newState.todos[0].done).toBe(true);
});
it('should not mutate original state', () => {
const state: TodoState = { todos: [] };
const action: TodoAction = { type: 'ADD_TODO', text: 'Test' };
const newState = todoReducer(state, action);
expect(state.todos).toHaveLength(0); // Original unverändert
expect(newState.todos).toHaveLength(1);
});
});
Keine komplexen Test-Setups, kein Mocking, keine Rendering-Tests. Nur pure Function Inputs und Outputs. Das macht Tests schnell, zuverlässig und einfach zu schreiben.
Ein bewährtes Pattern für async Operations mit
useReducer:
function DataComponent() {
const [state, dispatch] = useReducer(fetchReducer, {
status: 'idle',
data: null,
error: null
});
const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const data = await api.getData();
dispatch({ type: 'FETCH_SUCCESS', data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', error: error.message });
}
};
useEffect(() => {
fetchData();
}, []);
if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <Error message={state.error} />;
if (state.status === 'success') return <Data data={state.data} />;
return null;
}
Die async-Logik lebt außerhalb des Reducers (in
fetchData). Der Reducer bleibt pure und verwaltet nur den
State-Übergang.
Für optimistische Updates:
case 'TODO_TOGGLE_OPTIMISTIC':
// Sofort UI aktualisieren
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
)
};
case 'TODO_TOGGLE_ROLLBACK':
// Bei Server-Fehler: Rollback
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
)
};
Die Component dispatched TODO_TOGGLE_OPTIMISTIC, macht
den API-Call, und dispatched entweder TODO_TOGGLE_SUCCESS
(confirming) oder TODO_TOGGLE_ROLLBACK (revert) basierend
auf dem Ergebnis.
useReducer ist kein Ersatz für useState. Es
ist eine Alternative für Szenarien, wo State-Logik komplex wird. Die
zentralisierte Logik, die stabilen Funktionen und die expliziten
State-Übergänge machen Anwendungen wartbarer und robuster – aber nur,
wenn die Komplexität diese Struktur rechtfertigt. Für einfache
State-Fälle bleibt useState die richtige Wahl.