If you have used hooks before in React, then you must have done local state management using useState()
. While it works completely fine for basic state manipulation, it does get tricky after multiple states are involved.
Here's a Problem
For instance, let's take an example of managing a state of a person.
const [name, setName] = useState('')
const [age, setAge] = useState('')
const [gender, setGender] = useState('')
const [profession, setProfession] = useState('')
In the above code, there are 4 different handlers for setting different instances of the local state. What if there are more than 20 instances of state and what if we have multiple nested instances like address fields? Have a look at this example.
const [name, setName] = useState('')
const [age, setAge] = useState('')
const [gender, setGender] = useState('')
const [profession, setProfession] = useState('')
const [address, setAddress] = useState({apartment: '', lines: {line1: '', line2:''}})
That will make things cumbersome for us to handle.
Introducing useReducer()
useReducer() is a React Hook that is specifically being used to handle complex state changes. It takes three arguments, reducer, initalState and intialFunction. It returns two entities, state and dispatch.
const [state, dispatch] = useReducer(reducer, intialState, initialFunction)
Brief overview of things involved in useReducer()
reducer: A reducer is a pure function that takes two arguments, previous state, and action, and returns the next state. In more simple words, the reducer takes action and a state and returns a new version of that state.
(previousState, action) => newState
intialState: An initial state is a state that you want to use across that component. Preferably, for a complex structure, an ideal choice would be to use a JSON object but it's not a mandatory condition, you can use any data type for the initial state.
const initialState = {
name: "",
age: "",
gender: "",
profession:""
address: {
apartment: "",
line1: "",
line2: ""
}
}
initialFunction: The initial function is used to load the initial state lazily which also means resetting the state to its initial value.
function init(initialState) {
return initialState;
}
Let's build something
Learning a programming concept is incomplete without actually building a to-do app. (Pun intended). The final Application will look like this.
In this application, we have mainly 5 main components, heading, button, input, ordered list and form so let's code a basic react component layout quickly.
I won't be explaining the CSS code since this post is particularly aimed to understand the hooks and not the design and it's a very basic CSS anyway.
styles.css
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;700&display=swap');
body {
font-family: 'Nunito', sans-serif;
background: rgb(8, 38, 53);
}
form {
display: flex;
justify-content: space-between;
align-items: center;
}
input[type='text'] {
outline: none;
border: 0;
border-radius: 25px;
padding: 10px 20px;
width: 350px;
}
button {
padding: 10px;
background: blue;
color: white;
border: 2px solid blue;
cursor: pointer;
border-radius: 5px;
}
.app {
padding: 0px 20px;
}
h1 {
color: white;
padding: 5px 10px;
}
.number {
display: flex;
justify-content: space-between;
margin: 20px 0;
font-size: 20px;
color: white;
}
.list {
background: rgb(245, 235, 98);
border-radius: 10px;
height: 310px;
overflow: auto;
color: brown;
}
ol li {
margin: 20px 0px;
}
.banner {
overflow: auto;
display: flex;
justify-content: center;
align-items: center;
}
App.tsx
import * as React from 'react';
import './styles.css';
const App = () => {
const [itemName, setItemName] = React.useState('');
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
setItemName('');
};
return (
<div className="app">
<h1>Todo List</h1>
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="Add a item name"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
/>
<button type="submit">Add item</button>
</form>
<div className="number">
<button>
Reset
</button>
Total Todos:
</div>
<ol className="list">
<li></li>
</ol>
</div>
);
};
export default App;
If you are familiar with hooks, then the above code is quite easy to understand. I am making use of the useState() hook to capture the value of the input field and name that state as itemName.
Let's add some more complexity to this application and try to do the basic operations like adding a new todo item and marking those items as either complete or incomplete. Since this will involve multiple states and their handlers, so good it's a time for adding useReducer hook.
const [{ todos, totalTodos }, dispatch] = React.useReducer(
reducer,
intialState,
init
);
Let's write the reducer, initialState and init function and then try to understand what these entities really convey. But before even writing all these entities, let's first write the type definitions so that we can structure our application data well.
Type Definitions
type TodoType = {
idx?: number;
text?: string;
completed?: boolean;
};
type StateType = {
todos: Array<any>;
totalTodos: number;
};
type ActionType = {
type: string;
payload: any;
};
Action Types
const AddTodo = 'add-todo';
const MarkCompleted = 'mark-completed';
const MarkIncompleted = 'mark-incompleted';
const Reset = 'reset';
Initial State and Init function
const intialState = {
todos: [],
totalTodos: 0,
};
const init = (initialState: StateType) => initialState;
Reducer
const reducer = (state: StateType, action: ActionType): StateType => {
const { payload } = action;
switch (action.type) {
case AddTodo:
return {
todos: [...state.todos, { text: payload.text, completed: false }],
totalTodos: state.totalTodos + 1,
};
case MarkCompleted:
return {
todos: state.todos.map((todo, idx) => {
return idx === payload.idx ? { ...todo, completed: true } : todo;
}),
totalTodos:
[...state.todos.filter((todo) => !todo.completed)].length - 1,
};
case MarkIncompleted:
return {
todos: state.todos.map((todo, idx) => {
return idx === payload.idx ? { ...todo, completed: false } : todo;
}),
totalTodos:
[...state.todos.filter((todo) => !todo.completed)].length + 1,
};
case Reset:
return init(action.payload);
default:
throw new Error('Not compatible action');
}
};
As we know, a reducer function accepts state and action and returns a new version of the state. Our application has 4 different scenarios that affect our state which I am handling using switch case inside reducer.
- AddToDo: In this case, I am simply adding a new item inside todos array of state and iterating the totalTodos inside state.
- MarkCompleted: This a bit tricky because this involves the update operation of marking an item as done, basically making the boolean value of completed inside todo state as true. Also, need to calculate the totalTodos pending here so need to filter the whole todos array where the completed value is false which states that the number of pending items.
- MarkIncompleted: It's similar to MarkCompleted operation except that instead of marking the value as true we are marking it as true. This comes handy when you wanna do an undo operation after marking the item as done.
- Reset: Reset/Clear operation comes handy when you want to either delete everything or basically go to your app's initial state.
Putting all these together and we have..
import * as React from 'react';
import './styles.css';
// type definitions
type TodoType = {
idx?: number;
text?: string;
completed?: boolean;
};
type StateType = {
todos: Array<any>;
totalTodos: number;
};
type ActionType = {
type: string;
payload: any;
};
// action types
const AddTodo = 'add-todo';
const MarkCompleted = 'mark-completed';
const MarkIncompleted = 'mark-incompleted';
const Reset = 'reset';
// reducer
const reducer = (state: StateType, action: ActionType): StateType => {
const { payload } = action;
switch (action.type) {
case AddTodo:
return {
todos: [...state.todos, { text: payload.text, completed: false }],
totalTodos: state.totalTodos + 1,
};
case MarkCompleted:
return {
todos: state.todos.map((todo, idx) => {
return idx === payload.idx ? { ...todo, completed: true } : todo;
}),
totalTodos:
[...state.todos.filter((todo) => !todo.completed)].length - 1,
};
case MarkIncompleted:
return {
todos: state.todos.map((todo, idx) => {
return idx === payload.idx ? { ...todo, completed: false } : todo;
}),
totalTodos:
[...state.todos.filter((todo) => !todo.completed)].length + 1,
};
case Reset:
return init(action.payload);
default:
throw new Error('Not compatible action');
}
};
// initial state
const intialState = {
todos: [],
totalTodos: 0,
};
// init function
const init = (initialState: StateType) => initialState;
const App = () => {
const [{ todos, totalTodos }, dispatch] = React.useReducer(
reducer,
intialState,
init
);
const [itemName, setItemName] = React.useState('');
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
dispatch({
type: AddTodo,
payload: {
text: itemName,
},
});
setItemName('');
};
return (
<div className="app">
<h1>Todo List</h1>
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="Add a item name"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
/>
<button type="submit">Add item</button>
</form>
<div className="number">
<button onClick={() => dispatch({ type: Reset, payload: intialState })}>
Reset
</button>
Total Todos: {totalTodos}
</div>
<ol className="list">
{todos.map((todo, idx) => {
return (
<li
key={idx}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
onClick={() =>
todo.completed
? dispatch({
type: MarkIncompleted,
payload: { ...todo, idx: idx },
})
: dispatch({
type: MarkCompleted,
payload: { ...todo, idx: idx },
})
}
>
{todo.text}
</li>
);
})}
</ol>
</div>
);
};
export default App;
In order to make our button even handlers work, we can't just go ahead and simply call the function in this scenario where we are using the useReducer() hook. If you are familiar with Flux pattern, then you must know that we have to dispatch an action that further involves a type and a payload, and based on certain scenarios, our reducer returns the next state. Hence, to make our buttons work properly, I am dispatching the actions which involve the type and the payload and our reducer takes care of these actions and returns us the new version of the state.
Codesandbox
useState() hook can be implemented using useReducer() because inside React, they both share the common code.
When to use useReducer and useState()?
This itself is a long topic to understand but in short, if you wanna have some ground rules for application (which I highly recommend because those will make your life easier), then here they are:
If only 1 instance of the state is involved, then definitely go with useState() hook. Why make things complicated anyway?.
If your instance of the state is dependent on the other one, like in our example above, the number of todos was dependent on todos array then we should use useReducer() hook.
Since this post is particularly targetted to understand useReducer() hook, I don't wanna go in detail about implementing useState() with useReducer() or explanation of the above rules in detail because if you are new to useReducer() then you might get confused a bit.
However, Kent C. Dodds has written awesome stuff and he has explained all this in even more detail. So definitely check that out if you are interested to dig even more.