Skip to main content

React useReducer TypeScript: Typing Actions and State

useReducer is React's tool for state machines: when state changes are interdependent or follow complex rules, the reducer pattern enforces those rules at the type level. By typing the action union and leveraging discriminated unions, TypeScript ensures every dispatch call is valid and every state transition is accounted for.

In enterprise applications I've worked on, untyped reducers were a common source of silent bugs—actions dispatched with wrong payload shapes, or state transitions that weren't handled. TypeScript caught all of these before they reached production.

What Is a Reducer and Why Type It?

A reducer is a function that takes the current state and an action, and returns the next state. In plain JavaScript, an action is just an object, often with a type string and optional properties. TypeScript's discriminated unions transform this loose contract into a strict one.

Here's a simple, untyped reducer:

// Untyped—the type of `action` is any
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return state + action.payload; // runtime error if payload missing
case 'RESET':
return 0;
default:
return state;
}
}

The problem: when you dispatch an action, JavaScript doesn't validate that action matches the reducer's expectations. You might forget the payload field, or dispatch an action with the wrong type.

Typing the action union fixes this:

type CounterAction = 
| { type: 'INCREMENT'; payload: number }
| { type: 'DECREMENT'; payload: number }
| { type: 'RESET' };

function counterReducer(state: number, action: CounterAction): number {
switch (action.type) {
case 'INCREMENT':
return state + action.payload;
case 'DECREMENT':
return state - action.payload;
case 'RESET':
return 0;
default:
// TypeScript ensures all action types are handled
const _exhaustive: never = action;
return _exhaustive;
}
}

Now TypeScript validates at the call site. If you dispatch without the payload field or with an unknown type, you get a compile error immediately.

Discriminating Action Types

A discriminated union uses the type field (or any shared field) to distinguish action variants. TypeScript narrows the action type within each case branch:

type TodoAction =
| { type: 'ADD_TODO'; id: number; text: string }
| { type: 'REMOVE_TODO'; id: number }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'EDIT_TODO'; id: number; newText: string };

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case 'ADD_TODO':
// Inside this case, TypeScript knows action has id and text
return [...state, { id: action.id, text: action.text, completed: false }];
case 'REMOVE_TODO':
// Inside this case, action has only id
return state.filter((todo) => todo.id !== action.id);
case 'EDIT_TODO':
// TypeScript knows action has id and newText
return state.map((todo) =>
todo.id === action.id ? { ...todo, text: action.newText } : todo
);
default:
const _exhaustive: never = action;
return _exhaustive;
}
}

The default case assigns the action to a variable of type never. If any action type is not handled, TypeScript reports an error. This pattern is called exhaustiveness checking and guarantees you've covered every action variant.

Using useReducer with TypeScript

Here's a complete example integrating useReducer into a React component:

interface Todo {
id: number;
text: string;
completed: boolean;
}

type TodoAction =
| { type: 'ADD_TODO'; id: number; text: string }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'REMOVE_TODO'; id: number };

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: action.id, text: action.text, completed: false }];
case 'TOGGLE_TODO':
return state.map((t) =>
t.id === action.id ? { ...t, completed: !t.completed } : t
);
case 'REMOVE_TODO':
return state.filter((t) => t.id !== action.id);
default:
const _exhaustive: never = action;
return _exhaustive;
}
}

function TodoApp() {
const [todos, dispatch] = React.useReducer(todoReducer, []);

const addTodo = (text: string) => {
dispatch({ type: 'ADD_TODO', id: Date.now(), text });
};

const toggleTodo = (id: number) => {
dispatch({ type: 'TOGGLE_TODO', id });
};

return (
<div>
<button onClick={() => addTodo('New task')}>Add Todo</button>
{todos.map((todo) => (
<div key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
</div>
))}
</div>
);
}

When you call dispatch, TypeScript validates the entire action object. If you forget a required field or use the wrong type string, you get a compile error before runtime.

Generic Reducers for Reuse

For reducers used across multiple components, use generic type parameters to allow flexibility:

type LoadingAction<T> =
| { type: 'LOADING' }
| { type: 'SUCCESS'; payload: T }
| { type: 'ERROR'; error: string };

interface LoadingState<T> {
data: T | null;
loading: boolean;
error: string | null;
}

const initialLoadingState = <T,>(): LoadingState<T> => ({
data: null,
loading: false,
error: null,
});

function loadingReducer<T>(
state: LoadingState<T>,
action: LoadingAction<T>
): LoadingState<T> {
switch (action.type) {
case 'LOADING':
return { ...state, loading: true, error: null };
case 'SUCCESS':
return { data: action.payload, loading: false, error: null };
case 'ERROR':
return { ...state, loading: false, error: action.error };
default:
const _exhaustive: never = action;
return _exhaustive;
}
}

// Usage: TypeScript infers T as User
interface User {
id: number;
name: string;
}

function UserProfile() {
const [state, dispatch] = React.useReducer(
loadingReducer<User>,
initialLoadingState<User>()
);

return <div>{state.data?.name}</div>;
}

Generics allow the same reducer to work for any data type, while preserving type safety for callers.

Combining Multiple Reducers

In large applications, split reducers by concern and combine them using helper patterns:

// Separate reducers for orthogonal state
function authReducer(state: AuthState, action: AuthAction): AuthState { /* ... */ }
function uiReducer(state: UIState, action: UIAction): UIState { /* ... */ }

// Combined reducer
type AppAction = AuthAction | UIAction;
type AppState = { auth: AuthState; ui: UIState };

function appReducer(state: AppState, action: AppAction): AppState {
return {
auth: authReducer(state.auth, action as AuthAction),
ui: uiReducer(state.ui, action as UIAction),
};
}

This approach keeps each reducer focused and testable, while the type union ensures all actions are handled.

Key Takeaways

  • Discriminated unions (type field) make action shapes explicit and enable TypeScript to narrow types within each reducer case.
  • Exhaustiveness checking (the never pattern) guarantees every action type is handled.
  • Generic reducers allow reuse across components while preserving type safety for the specific data type.
  • Combine multiple reducers by creating a union of all action types and delegating to sub-reducers.
  • Always type both state and action to catch reducer bugs at compile time.

Frequently Asked Questions

What is exhaustiveness checking and how do I implement it?

In the default case of a switch statement, assign the action to a never variable. If any action variant is not handled, TypeScript reports an error. This ensures all action types are accounted for.

Can I dispatch multiple action types in the same reducer?

Yes—create a union of all action types. TypeScript uses the type field to narrow the action shape, so it validates each dispatch call against the full union.

Should I split state across multiple useReducer calls or combine them?

Split if state concerns are independent (e.g., form state and loading state). Combine with a single reducer if state changes are interdependent (e.g., user data and fetch status tied together).

How do I handle async actions in a typed reducer?

Reducers must be synchronous. For async, dispatch a "LOADING" action before fetching, then "SUCCESS" or "ERROR" when the fetch completes. Use useEffect to trigger the fetch and dispatch actions.

Further Reading