17 useReducer – Zustandslogik strukturieren

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.

17.1 Das Problem: Verteilte State-Logik

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.

17.2 Das Reducer-Pattern: Zentralisierte Logik

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.

17.3 useReducer: Die API

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.

17.4 TypeScript: Discriminated Unions

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.

17.5 Die Stabilität von dispatch

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.

17.6 State-Maschinen modellieren

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.

17.7 Integration mit Context: Mini-Redux

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

17.8 Wann useState, wann useReducer?

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.

17.9 Testing: Reducer isoliert prüfen

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.

17.10 Praktische Patterns

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.