The "useReducer received an invalid reducer function" error occurs when React's useReducer hook receives a reducer function that doesn't meet the required criteria. This typically happens when the reducer mutates state directly, doesn't return a value for all action types, or has an incorrect function signature.
This error occurs when React's useReducer hook receives a reducer function that violates one or more of React's requirements for reducer functions. Reducers in React must be pure functions that take the current state and an action, then return the next state without side effects. When React detects that a reducer function doesn't meet these criteria, it throws this error to prevent unpredictable behavior. The error is a safeguard against common mistakes that can lead to bugs that are difficult to debug, such as state mutations that don't trigger re-renders or inconsistent state updates. Understanding this error is crucial for working with complex state logic in React applications, as useReducer is often used to manage state that involves multiple sub-values or when the next state depends on the previous one in non-trivial ways.
Check that your reducer always returns a new state object instead of mutating the existing one:
// ❌ WRONG - mutating state directly
function reducer(state, action) {
switch (action.type) {
case 'increment':
state.count = state.count + 1; // Mutation!
return state;
default:
return state;
}
}
// ✅ CORRECT - returning new state
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {
...state,
count: state.count + 1
};
default:
return state;
}
}For arrays, use methods that return new arrays:
// ❌ WRONG - mutating array
case 'add_item':
state.items.push(action.item); // Mutation!
return state;
// ✅ CORRECT - returning new array
case 'add_item':
return {
...state,
items: [...state.items, action.item]
};Ensure your reducer handles all possible action types, including unknown ones:
// ❌ WRONG - no default case
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
// Missing default case!
}
}
// ✅ CORRECT - with default case
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state; // Always return current state for unknown actions
}
}Alternatively, you can throw an error for unknown actions:
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}Check that your reducer accepts exactly two parameters (state, action) and always returns a value:
// ❌ WRONG - incorrect signature
function reducer() { // Missing parameters
return { count: 0 };
}
// ❌ WRONG - missing return
function reducer(state, action) {
switch (action.type) {
case 'increment':
const newCount = state.count + 1;
// No return statement!
}
}
// ✅ CORRECT - proper signature and returns
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}Also ensure each case block has its own scope with curly braces:
// ❌ RISKY - variables might clash between cases
case 'add_item':
const newItem = action.item;
return { ...state, items: [...state.items, newItem] };
case 'remove_item':
const itemId = action.id; // Same scope as newItem!
return { ...state, items: state.items.filter(item => item.id !== itemId) };
// ✅ CORRECT - each case has its own scope
case 'add_item': {
const newItem = action.item;
return { ...state, items: [...state.items, newItem] };
}
case 'remove_item': {
const itemId = action.id;
return { ...state, items: state.items.filter(item => item.id !== itemId) };
}Ensure your reducer doesn't contain any side effects like API calls, alerts, or timeouts:
// ❌ WRONG - side effects in reducer
function reducer(state, action) {
switch (action.type) {
case 'fetch_data':
fetch('/api/data') // Side effect!
.then(response => response.json())
.then(data => {
// Can't return from here
});
return state;
case 'show_message':
alert(action.message); // Side effect!
return { ...state, message: action.message };
}
}
// ✅ CORRECT - side effects in event handlers or useEffect
function reducer(state, action) {
switch (action.type) {
case 'set_data':
return { ...state, data: action.data };
case 'set_message':
return { ...state, message: action.message };
default:
return state;
}
}
// Handle side effects elsewhere
function MyComponent() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
fetch('/api/data')
.then(response => response.json())
.then(data => {
dispatch({ type: 'set_data', data });
});
}, []);
const handleClick = () => {
alert(state.message);
};
return <button onClick={handleClick}>Show Message</button>;
}Write simple tests to verify your reducer works correctly:
// reducer.test.js
import { reducer } from './reducer';
describe('reducer', () => {
const initialState = { count: 0 };
test('handles increment action', () => {
const newState = reducer(initialState, { type: 'increment' });
expect(newState).toEqual({ count: 1 });
expect(newState).not.toBe(initialState); // Should be new object
});
test('handles decrement action', () => {
const newState = reducer(initialState, { type: 'decrement' });
expect(newState).toEqual({ count: -1 });
});
test('returns current state for unknown action', () => {
const newState = reducer(initialState, { type: 'unknown' });
expect(newState).toBe(initialState); // Should return same reference
});
test('does not mutate original state', () => {
const originalState = { count: 0 };
const newState = reducer(originalState, { type: 'increment' });
expect(originalState.count).toBe(0); // Original unchanged
expect(newState.count).toBe(1);
});
});Run these tests to ensure your reducer is pure and handles all cases correctly.
For complex state structures, consider using Immer to simplify reducer logic while maintaining purity:
npm install immerimport { produce } from 'immer';
function reducer(state, action) {
return produce(state, draftState => {
switch (action.type) {
case 'add_item':
draftState.items.push(action.item); // Safe mutation with Immer
break;
case 'update_user':
draftState.user.name = action.name;
draftState.user.email = action.email;
break;
case 'nested_update':
draftState.data.users[action.userId].profile.avatar = action.avatar;
break;
default:
break;
}
});
}Immer creates a "draft" state that you can mutate, then automatically produces an immutable update. This makes complex updates much more readable while keeping the reducer pure.
Ensure your action objects contain all required fields and have the correct structure:
// ❌ WRONG - missing required data
dispatch({ type: 'update_user' });
// Reducer expects action.name and action.email
// ✅ CORRECT - complete action object
dispatch({
type: 'update_user',
name: 'John',
email: '[email protected]'
});
// Consider using action creator functions
function updateUser(name, email) {
return { type: 'update_user', name, email };
}
dispatch(updateUser('John', '[email protected]'));Also ensure your reducer properly extracts data from actions:
function reducer(state, action) {
switch (action.type) {
case 'update_user':
// Always check for existence if fields might be missing
return {
...state,
user: {
...state.user,
name: action.name || state.user.name,
email: action.email || state.user.email
}
};
default:
return state;
}
}When working with TypeScript, you can add strong typing to prevent many of these errors:
type Action =
| { type: 'increment'; amount: number }
| { type: 'decrement'; amount: number }
| { type: 'reset' };
type State = { count: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + action.amount };
case 'decrement':
return { count: state.count - action.amount };
case 'reset':
return { count: 0 };
default:
// TypeScript ensures all cases are handled
const _exhaustiveCheck: never = action;
return _exhaustiveCheck;
}
}TypeScript will catch missing cases, incorrect action types, and missing properties at compile time.
For very large applications, consider splitting reducers using the "reducer composition" pattern or using libraries like Redux Toolkit which provides utilities like createSlice that automatically generate action creators and reducers with proper immutability.
React Hook useCallback has a missing dependency: 'variable'. Either include it or remove the dependency array react-hooks/exhaustive-deps
React Hook useCallback has a missing dependency
Cannot use private fields in class components without TS support
Cannot use private fields in class components without TS support
Cannot destructure property 'xxx' of 'undefined'
Cannot destructure property of undefined when accessing props
useNavigate() may be used only in the context of a <Router> component.
useNavigate() may be used only in the context of a Router component
Cannot find module or its corresponding type declarations
How to fix "Cannot find module or type declarations" in Vite