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.
[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
todoList
by callingsetTodoList([...todoList, {id, text: input}])
in whichid
is 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
undoHistory
by callingsetUndoHistory([...undoHistory, todoToBeDeleted])
- Remove the todo from
todoList
by 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.
inputText
is reset to an empty stringtodos
is now updated to include all the currenttodos
and the new one from theaction.payload
undoHistory
is the current one fromstate
When deleting a todo;
- find the
todo
that is to be deleted todos
is now all the current todos except the todo to be deletedundoHistory
is now the currentundoHistory
and todo to be deleted.inputText
is 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.