Combining useReducer hook with Typescript
The useReducer hook in React is an alternative to useState for managing more complex state logic, especially when the next state depends on the previous one or when the state logic involves multiple sub-values. It's often preferred over useState when dealing with complex state transitions that might otherwise lead to a lot of nested if/else statements or switch cases within your component.
When combined with TypeScript, useReducer becomes even more powerful as TypeScript helps you define strict types for your state, actions, and reducer function, leading to more robust and maintainable code.
Here's a detailed explanation of how to apply the useReducer hook using TypeScript, along with examples:
- Combining
useReducerhook withTypescript
Understanding the Core Concepts
Before diving into the TypeScript specifics, let's briefly review the core concepts of useReducer:
- State: The data that your component manages.
- Action: A plain JavaScript object that describes what happened. Actions typically have a
typeproperty (a string) and optionally apayloadproperty (any data related to the action). - Reducer Function: A pure function that takes the current
stateand anactionas arguments and returns thenew state. It's crucial that the reducer function is pure, meaning it doesn't cause any side effects (like API calls or DOM manipulation) and always returns the same output for the same input. dispatchFunction: A function returned byuseReducerthat you use to send actions to the reducer. When you calldispatchwith an action, the reducer function is executed with the current state and that action, and the component re-renders with the new state.
useReducer Signature
The useReducer hook has the following signature:
const [state, dispatch] = useReducer(reducer, initialState, init);reducer: Your reducer function.initialState: The initial state value.init(optional): An initializer function that computes the initial state lazily. If provided,initialStateis passed toinit, andinit's return value is used as the initial state. This is useful for expensive initial state calculations.
TypeScript and useReducer
TypeScript enhances useReducer by allowing you to define the types for:
- State: The shape of your state object.
- Actions: The different types of actions your reducer can handle, including their
typeandpayload(if any). - Reducer Function: The types of its arguments (
state,action) and its return value (state).
Let's break it down with an example: a simple counter with increment, decrement, and reset functionality.
Example: Simple Counter
Step 1: Define the State Type
First, define the type for your state. For a simple counter, it might just be a number.
// types.ts or directly in your component file
interface CounterState {
count: number;
}Step 2: Define Action Types
Next, define the types for your actions. This is often done using a discriminated union, which is a powerful TypeScript feature for representing a fixed set of distinct types.
// types.ts
type CounterAction =
| { type: "increment"; payload: number }
| { type: "decrement"; payload: number }
| { type: "reset" };Here:
'increment'and'decrement'actions have apayloadof typenumber.'reset'action has no payload.
Step 3: Create the Reducer Function-1
Now, write your reducer function, making sure to type its arguments and return value.
// counterReducer.ts or directly in your component file
function counterReducer(
state: CounterState,
action: CounterAction
): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + action.payload };
case "decrement":
return { count: state.count - action.payload };
case "reset":
return { count: 0 };
default:
// It's good practice to throw an error for unknown action types
// or return the current state for unhandled actions.
// TypeScript will often infer that this branch is unreachable if your action types cover all possibilities.
const exhaustiveCheck: never = action; // This ensures all action types are handled
throw new Error(`Unhandled action type: ${exhaustiveCheck}`);
}
}state: CounterState: Ensures thestateargument conforms toCounterState.action: CounterAction: Ensures theactionargument conforms toCounterAction.: CounterState: Ensures the function returns aCounterStateobject.- The
defaultcase withexhaustiveCheckis a TypeScript pattern to ensure that all possibleaction.typevalues are explicitly handled in yourswitchstatement. If you later add a new action type toCounterActionbut forget to add acasefor it, TypeScript will flag an error here.
Step 4: Use useReducer in your Component
Finally, integrate useReducer into your React component.
// Counter.tsx
import React, { useReducer } from "react";
interface CounterState {
count: number;
}
type CounterAction =
| { type: "increment"; payload: number }
| { type: "decrement"; payload: number }
| { type: "reset" };
function counterReducer(
state: CounterState,
action: CounterAction
): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + action.payload };
case "decrement":
return { count: state.count - action.payload };
case "reset":
return { count: 0 };
default:
const exhaustiveCheck: never = action;
throw new Error(`Unhandled action type: ${exhaustiveCheck}`);
}
}
const initialState: CounterState = { count: 0 };
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<h1>Count: {state.count}</h1>
<button onClick={() => dispatch({ type: "increment", payload: 1 })}>
Increment by 1
</button>
<button onClick={() => dispatch({ type: "increment", payload: 5 })}>
Increment by 5
</button>
<button onClick={() => dispatch({ type: "decrement", payload: 1 })}>
Decrement by 1
</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
export default Counter;In this component:
useReducer(counterReducer, initialState):- TypeScript infers the type of
stateto beCounterStatebased oninitialStateand thereducer's return type. - TypeScript infers the type of
dispatchbased on theactionargument ofcounterReducer, meaningdispatchwill only accept actions conforming toCounterAction.
- TypeScript infers the type of
More Complex Example: Todo List
Let's consider a slightly more complex scenario: a Todo List application.
Step 1: Define State Types
// types.ts
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
}Step 2: Define Action Types-1
// types.ts
type TodoAction =
| { type: "ADD_TODO"; payload: { text: string } }
| { type: "TOGGLE_TODO"; payload: { id: string } }
| { type: "REMOVE_TODO"; payload: { id: string } };Step 3: Create the Reducer Function
// todoReducer.ts
import { Todo, TodoState, TodoAction } from "./types"; // Assuming types.ts
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case "ADD_TODO":
return {
todos: [
...state.todos,
{
id: Date.now().toString(),
text: action.payload.text,
completed: false,
},
],
};
case "TOGGLE_TODO":
return {
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
),
};
case "REMOVE_TODO":
return {
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
default:
const exhaustiveCheck: never = action;
throw new Error(`Unhandled action type: ${exhaustiveCheck}`);
}
}Step 4: Use useReducer in your Component-1
// TodoApp.tsx
import React, { useReducer, useState } from 'react';
import { Todo, TodoState, TodoAction } from './types'; // Assuming types.ts
import { todoReducer } from './todoReducer'; // Assuming todoReducer.ts
const initialState: TodoState = {
todos: [],
};
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const [newTodoText, setNewTodoText] = useState('');
const handleAddTodo = () => {
if (newTodoText.trim()) {
dispatch({ type: 'ADD_TODO', payload: { text: newTodoText } });
setNewTodoText('');
}
};
return (
<div>
<h1>Todo List</h1>
<div>
<input
type="text"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new todo"
/>
<button onClick={handleAddTodo}>Add Todo</button>
</div>
<ul>
{state.todos.map((todo) => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
<button onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: { id: todo.id } })}>
Toggle
</button>
<button onClick={() => dispatch({ type: 'REMOVE_TODO', payload: { id: todo.id } })}>
Remove
</button>
</li>
))}
</ul>
</div>
);
}
export default TodoApp;Benefits of using useReducer with TypeScript
- Type Safety: TypeScript ensures that your state, actions, and reducer functions adhere to their defined types, catching errors at compile time rather than runtime.
- Improved Readability: Explicitly defined types make it clear what data shapes your state and actions take, improving code understanding for you and other developers.
- Better Maintainability: When you modify state or action structures, TypeScript will highlight all places that need updates, preventing inconsistencies.
- Enhanced Autocompletion: Your IDE will provide excellent autocompletion for action types and payload properties, making development faster and less error-prone.
- Centralized State Logic: The reducer function centralizes all state transition logic, making it easier to reason about and test independently.
- Scalability: For larger applications with complex state,
useReducerprovides a more scalable and organized approach than managing manyuseStatehooks.
When to use useReducer vs useState
useState: Ideal for simple state (e.g., boolean flags, numbers, strings, simple objects) where updates are straightforward.useReducer: Preferred for:- Complex state logic: When state transitions involve multiple values or complex calculations.
- Related state: When state updates often depend on the previous state.
- Performance optimizations: If dispatching many updates,
useReducercan sometimes be more performant than multipleuseStatecalls because React batches updates. - Sharing state logic: The reducer function can be externalized and reused across multiple components.
By combining useReducer with TypeScript, you gain the benefits of predictable state management and the robustness of static type checking, leading to more reliable and easier-to-maintain React applications.