best practices

State Management Using Reducers

The code provided in this article is simplified as much as possible to avoid overwhelming the reader with code and to convey the essence of the problem and its solution. In interactive examples, heading tags (`h1...h6`) are replaced with span tags to prevent potential conflicts with the main page structure.

Sometimes we need a more powerful way to define and manage state than the useState hook. Try using the useReducer hook before turning to third-party libraries. It's a great tool for managing complex state without requiring additional dependencies. In combination with context and TypeScript, the useReducer hook can be very powerful. Unfortunately, it's not used very often because people tend to prefer specialized libraries. That said, when managing multiple pieces of state, moving them into a reducer can bring structure and clarity to your code. Let's consider this with an example.

Approach using useState

The code below demonstrates how to manage the state of notes using useState hook. In this example, we create a note-taking application that allows you to add, archive, and delete notes.

Code implementation
1let nextId = 3;
2
3interface INote {
4 id: number;
5 text: string;
6 archived: boolean;
7}
8
9const initialNotes: INote[] = [
10 {
11 id: 0,
12 text: 'Project name idea: "Blue Sky"',
13 archived: false,
14 },
15 {
16 id: 1,
17 text: 'Book recommendation: "Gam" by Erich Maria Remarque',
18 archived: false,
19 },
20 {
21 id: 2,
22 text: 'Style idea: Green scarf with blue pants',
23 archived: false,
24 },
25];
26
27const NotesApp: FC = () => {
28 const [notes, setNotes] = useState(initialNotes);
29
30 const [inputValue, setInputValue] = useState('');
31
32 const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
33 setInputValue(e.target.value);
34 };
35
36 const handleAddNote = () => {
37 if (inputValue.trim() !== '') {
38 const newNote: INote = {
39 id: nextId++,
40 text: inputValue,
41 archived: false,
42 };
43
44 setNotes([...notes, newNote]);
45
46 setInputValue('');
47 }
48 };
49
50 const handleArchiveNote = (noteId: number) => {
51 const updatedNotes = notes.map((note) =>
52 note.id === noteId ? { ...note, archived: true } : note
53 );
54
55 setNotes(updatedNotes);
56 };
57
58 const handleDeleteNote = (noteId: number) => {
59 const filteredNotes = notes.filter((note) => note.id !== noteId);
60
61 setNotes(filteredNotes);
62 };
63
64 return (
65 <div>
66 <label>
67 New note
68 <input
69 type="text"
70 name="note"
71 value={inputValue}
72 onChange={handleChange}
73 placeholder="Enter your note..."
74 />
75 </label>
76 <button onClick={handleAddNote}>Add Note</button>
77 <ul>
78 {notes.map((note) => (
79 <li key={note.id}>
80 {note.text}
81 <div>
82 <button onClick={() => handleDeleteNote(note.id)}>
83 Delete
84 </button>
85 <button onClick={() => handleArchiveNote(note.id)}>
86 Archive
87 </button>
88 </div>
89 </li>
90 ))}
91 </ul>
92 </div>
93 );
94};
95

However, this approach has its drawbacks. The state management logic becomes scattered and cumbersome within the component itself. As the application grows, adding new actions or state leads to further code clutter, making it harder to maintain and test. Managing complex state through multiple useState hooks can become inefficient and error-prone, especially in large projects.

Approach using useReducer

Now let's take a look at the same functionality, but using useReducer hook:

Code implementation
1let nextId = 3;
2
3export interface INote {
4 id: number;
5 text: string;
6 archived: boolean;
7}
8
9export enum Actions {
10 ADD_NOTE,
11 ARCHIVE_NOTE,
12 DELETE_NOTE,
13}
14
15type ActionType =
16 | { type: Actions.ADD_NOTE; payload: string }
17 | { type: Actions.ARCHIVE_NOTE; payload: number }
18 | { type: Actions.DELETE_NOTE; payload: number };
19
20const notesReducer = (state: INote[], action: ActionType): INote[] => {
21 switch (action.type) {
22 case Actions.ADD_NOTE: {
23 const newNote: INote = {
24 id: nextId++,
25 text: action.payload,
26 archived: false,
27 };
28 return [...state, newNote];
29 }
30 case Actions.ARCHIVE_NOTE:
31 return state.map((note) =>
32 note.id === action.payload ? { ...note, archived: true } : note
33 );
34 case Actions.DELETE_NOTE:
35 return state.filter((note) => note.id !== action.payload);
36 default:
37 return state;
38 }
39};
40
41export default notesReducer;
42
Code implementation
1const initialNotes: INote[] = [
2 {
3 id: 0,
4 text: 'Project name idea: "Blue Sky"',
5 archived: false,
6 },
7 {
8 id: 1,
9 text: 'Book recommendation: "Gam" by Erich Maria Remarque',
10 archived: false,
11 },
12 {
13 id: 2,
14 text: 'Style idea: Green scarf with blue pants',
15 archived: false,
16 },
17];
18
19const NotesApp = () => {
20 const [notes, dispatch] = useReducer(notesReducer, initialNotes);
21
22 const [inputValue, setInputValue] = useState('');
23
24 const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
25 setInputValue(e.target.value);
26 };
27
28 const handleAddNote = () => {
29 if (inputValue.trim() !== '') {
30 dispatch({ type: Actions.ADD_NOTE, payload: inputValue });
31
32 setInputValue('');
33 }
34 };
35
36 const handleArchiveNote = (noteId: number) => {
37 dispatch({ type: Actions.ARCHIVE_NOTE, payload: noteId });
38 };
39
40 const handleDeleteNote = (noteId: number) => {
41 dispatch({ type: Actions.DELETE_NOTE, payload: noteId });
42 };
43
44 return (
45 <div>
46 <label>
47 New note
48 <input
49 type="text"
50 name="note"
51 value={inputValue}
52 onChange={handleChange}
53 placeholder="Enter your note..."
54 />
55 </label>
56 <button onClick={handleAddNote}>Add Note</button>
57 <ul>
58 {notes.map((note) => (
59 <li key={note.id}>
60 {note.text}
61 <div>
62 <button onClick={() => handleDeleteNote(note.id)}>
63 Delete
64 </button>
65 <button onClick={() => handleArchiveNote(note.id)}>
66 Archive
67 </button>
68 </div>
69 </li>
70 ))}
71 </ul>
72 </div>
73 );
74};
75

This approach allows us to manage state in a more structured and predictable way, especially when the state becomes complex. Extracting state management logic into a reducer simplifies the component itself, making it cleaner and easier to understand. When you need to add a new action or state, you don't have to clutter the component code — just make changes to the reducer.

Moreover, useReducer hook makes it easier to test and reuse state management logic. Reducers can be easily isolated and tested separately from components. This is particularly useful in large projects where complex state logic might be shared across multiple components. Additionally, using useReducer hook can improve performance by avoiding unnecessary re-renders that can occur with multiple useState hooks.

Using useReducer instead of useState can significantly simplify managing complex state in your application. It makes the code more structured and easier to maintain, especially as the application grows. Don't hesitate to use this powerful tool before resorting to third-party state management libraries.

Last updated on by @skrykateSuggest an improvement on Github repository.