Why useReducer is better at managing interdependent states

Why useReducer is better at managing interdependent states
Photo by Lautaro Andreani / Unsplash

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([]);
}
useState
  • [inputText, setUserInput] will be used to keep track of the todo text from the <input> field. When the user clicks add (or hits enter) 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 calling setTodoList([...todoList, {id, text: input}]) in which id 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 calling setUndoHistory([...undoHistory, todoToBeDeleted])
  • Remove the todo from todoList by calling setTodoList((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:

  1. inputText: This variable tracks the todo text entered in the <input> field.
  2. todos: An array that maintains a record of all the todos.
  3. 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 string
  • todos is now updated to include all the current todos and the new one from the action.payload
  • undoHistory is the current one from state

When deleting a todo;

  • find the todo that is to be deleted
  • todos is now all the current todos except the todo to be deleted
  • undoHistory is now the current undoHistory and todo to be deleted.
  • inputText is still the current one from state

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.