How to Secure useReducer Hook in React

The useReducer hook is a powerful tool for managing complex state in React, but it can introduce security vulnerabilities if state transitions and payloads are not properly handled. This article explains how to secure the useReducer hook by validating action payloads, enforcing strict type safety, preventing unauthorized state modifications, and implementing defensive programming practices in your React applications.

1. Validate and Sanitize Action Payloads

One of the primary security risks with useReducer is the injection of malicious data through action payloads. If a payload is rendered directly into the DOM or sent to an API without validation, it can lead to Cross-Site Scripting (XSS) or SQL injection.

Always validate and sanitize payloads before updating the state. You can use schema validation libraries like Zod or Yup inside the reducer to ensure the payload matches the expected format.

import { z } from 'zod';

const TodoPayloadSchema = z.object({
  text: z.string().min(1).max(100).trim(),
});

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      // Validate payload structure and sanitize input
      const validation = TodoPayloadSchema.safeParse(action.payload);
      if (!validation.success) {
        console.error('Invalid payload rejected:', validation.error);
        return state; // Reject the state transition
      }
      return {
        ...state,
        todos: [...state.todos, { text: validation.data.text, id: Date.now() }],
      };
    default:
      return state;
  }
}

2. Enforce Strict Type Safety

Using TypeScript with useReducer prevents developers from dispatching arbitrary, untyped actions. By defining strict Union Types for your actions, you ensure that only pre-defined action structures can ever trigger a state change.

type Action =
  | { type: 'INCREMENT'; payload: { step: number } }
  | { type: 'DECREMENT'; payload: { step: number } };

interface State {
  count: number;
}

function counterReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + action.payload.step };
    case 'DECREMENT':
      return { count: state.count - action.payload.step };
    default:
      return state;
  }
}

Enforcing these types at compile-time prevents accidental execution of unsupported actions that could put the application into an unstable or vulnerable state.

3. Prevent Prototype Pollution

If your reducer merges objects dynamically using spread operators (...) or Object.assign with untrusted payload data, it may be vulnerable to prototype pollution. Attackers can exploit this to modify the behavior of JavaScript base objects.

To prevent this, avoid recursive merging of uncontrolled inputs. If you must merge deeply nested structures, sanitize keys to block forbidden properties like __proto__, constructor, and prototype.

function safeMerge(target, source) {
  for (let key in source) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue; // Block potentially dangerous keys
    }
    target[key] = source[key];
  }
  return target;
}

4. Implement State Immutability

Reducers must never mutate the existing state directly. Direct mutations can cause unpredictable UI behavior, make debugging difficult, and bypass shallow comparison checks, which can lead to security-related UI desynchronization.

Always return a new state object. Consider using utility libraries like Immer to write safe, immutable state updates without the risk of accidental mutations.

import produce from 'immer';

const secureReducer = produce((draft, action) => {
  switch (action.type) {
    case 'UPDATE_PROFILE':
      // Draft is a proxy; modifications are safe and immutable
      draft.profile.name = action.payload.name;
      break;
  }
});

5. Separate Authorization from State Transitions

The useReducer hook manages local UI state, but it should not be the final authority on user permissions. Do not rely solely on the reducer to restrict access to sensitive actions.

Always verify user roles and permissions at the API layer or within your application’s global authentication context before dispatching actions that modify sensitive resources.

// Good practice: Check authorization before dispatching
const handleSensitiveAction = (user, dispatch) => {
  if (!user.isAdmin) {
    console.error("Unauthorized action attempt");
    return;
  }
  dispatch({ type: 'DELETE_DATABASE_RECORD' });
};