Why useReducer is better at managing interdependent states
When working with uncontrolled components that involve multiple interdependent state variables, employing useReducer proves to be a superior approach for managing these states compared to the conventional use of multiple variables created with useState. This method offers greater control, organization and testability when handling complex state interactions.
Interdependent states refer to situations where updating one variable necessitates the update of another. For example, in a Todo app with undo support, deleting a todo requires tracking the deleted todo in another state variable
In such scenarios, using useReducer to centralise the management of interdependent state logic into a single reducer function makes code more predictable, readable and above all easier to test, as it eliminates the need for DOM interactions typically required when dealing with multiple useState variables. It's worth noting that even with useReducer, you can still test components using DOM interactions when necessary.l.
useState
To demonstrate lets visit a Todo app that supports adding, deleting and undoing the deletion of the todos. To implement this the code will need three states like so.
import { useState } from "react";
const App = () => {
const [inputText, setUserInput] = useState("");
const [todoList, setTodoList] = useState([]);
const [undoHistory, setUndoHistory] = useState([]);
}[inputText, setUserInput]will be used to keep track of the todo text from the<input>field. When the user clicks add (or hitsenter) this field will be cleared.[todoList, setTodoList]is used to track the todos as an array.[undoHistory, setUndoHistory]is used to support undo history so when a todo is deleted, the deleted todo will be added here.
Let me start by describing the operations needed for adding a new todo;
- Update the
todoListby callingsetTodoList([...todoList, {id, text: input}])in whichidis unique - Reset input text by calling
setUserInput("")
To add a new todo the code has to interact with two state variables to achieve this.
Here is the operations needed when deleting a todo;
- Find the todo to be deleted from
todoList - Add this todo to
undoHistoryby callingsetUndoHistory([...undoHistory, todoToBeDeleted]) - Remove the todo from
todoListby callingsetTodoList((currentTodos) => currentTodos.filter((todo) => todo.id !== id));
In the complete implementation below, it's evident that every state change operation necessitates updates to two state variables simultaneously. This tight interdependence between state variables underscores the disadvance of using useState as there is higher mental load on the developer to keep track of state.
To write tests for this implementation, we can utilise React Testing Library to simulate user interactions with the DOM. This includes actions like adding text and submitting the form or clicking the delete and undo buttons, mirroring real user behavior for comprehensive testing.
useReducer
In the updated Todo application further down, state management is handled through useReducer with the help of a todoReducer.ts file that has the reducer function. The default state for todoReducer comprises three key variables:
- inputText: This variable tracks the todo text entered in the
<input>field. - todos: An array that maintains a record of all the todos.
- undoHistory: Another array dedicated to tracking deleted todos.
This version provides a clear and concise overview of the state variables managed by todoReducer.
Instead of describing the order of operations for adding a new todo I will describe what the next state is when adding todo.
inputTextis reset to an empty stringtodosis now updated to include all the currenttodosand the new one from theaction.payloadundoHistoryis the current one fromstate
When deleting a todo;
- find the
todothat is to be deleted todosis now all the current todos except the todo to be deletedundoHistoryis now the currentundoHistoryand todo to be deleted.inputTextis still the current one fromstate
With useReducer, each state update is a single operation, in contrast to the two operations required when using useState. This not only simplifies state management but also streamlines the testing process for todoReducer.
It's a straightforward function that takes the current state and an action as input, returning the next state, making testing in todoReducer.test.ts a straightforward and efficient task
Conclusion
When dealing with uncontrolled components that involve multiple interdependent states, useReducer offers a significantly more manageable approach to state management. Furthermore, it enables the adoption of a Test-Driven Development (TDD) approach, making the development process more structured and efficient.