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