How to Beat useState Overload with useReducer

Adriana Ito
4 min readMar 19, 2024

The Trouble with Too Much useState

In React, we often start by using useState for everything. It's like our go-to tool for keeping track of stuff that changes in our app. But when our app starts getting bigger and we're juggling lots of these useState hooks, things can get messy real fast. It's like trying to cook in a kitchen where every single ingredient is out on the counter. Chaos, right?

Why Too Many useState Can Be a Headache

  1. It Gets Complicated: Imagine you’re trying to keep track of a bunch of stuff that all depends on each other. Updating them separately can turn your code into a tangled mess.
  2. Slowdowns: If you’re updating a bunch of these states separately, your app might start lagging because it’s working overtime.
  3. Things Can Get Weird: Managing related stuff with separate useState hooks can sometimes make your app act up in ways you didn't expect.
  4. Repeating Yourself: You might end up writing the same kind of state management logic in more than one place, which is a pain to update and keep track of.

Enter useReducer

useReducer is like hiring an organizer for your app's state management. It's especially great when the changes in one part of your app depend a lot on changes in another.

How useReducer Helps

  • It Makes State Updates Predictable: With useReducer, all your updates go through one place, so you always know what's happening and when.
  • Better Performance: Because it’s more efficient, your app runs smoother, even with complex state logic.
  • Grows with You: useReducer is awesome for managing state that's shared across components or is just plain complicated.
  • Easier to Keep Track: Putting all your state logic in one spot makes your code cleaner and your life easier when it’s time to change things.
  • Simpler Testing: Testing your state logic is straightforward because you can focus on the reducer function, seeing how different actions change your app’s state.

Using useReducer: A Before-and-After Story

Before: A simple example with useState

Let’s say you’ve got a simple app where you can add todos, mark them as done, and filter them. Starting with useState, you've got hooks for todos, hooks for filters, and your code is starting to look like this:

"use client";

import React, { useState, KeyboardEvent } from "react";

type Todo = {
id: number;
text: string;
completed: boolean;
}

const TodoAppWithState: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");

const addTodo = (text: string): void => {
const newTodo: Todo = { id: Date.now(), text, completed: false };
setTodos((currentTodos) => [...currentTodos, newTodo]);
};

const toggleTodo = (id: number): void => {
setTodos((currentTodos) =>
currentTodos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};

const setVisibilityFilter = (
filter: "all" | "active" | "completed"
): void => {
setFilter(filter);
};

const getVisibleTodos = (): Todo[] => {
switch (filter) {
case "completed":
return todos.filter((t) => t.completed);
case "active":
return todos.filter((t) => !t.completed);
default:
return todos;
}
};

const visibleTodos = getVisibleTodos();

return (
<div>
<h2>Todos</h2>
<input
type="text"
placeholder="Add todo"
className="text-black"
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
addTodo(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
<div>
<button onClick={() => setVisibilityFilter("all")}>All</button>
<button onClick={() => setVisibilityFilter("active")}>Active</button>
<button onClick={() => setVisibilityFilter("completed")}>
Completed
</button>
</div>
<ul>
{visibleTodos.map((todo) => (
<li
key={todo.id}
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.completed ? "line-through" : "none" }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
};

export default TodoAppWithState;

After: Neat and Tidy with useReducer

Now let’s switch to useReducer. Instead of having the state logic all over the place, it's all in one place. You've got a clear set of actions for adding todos, toggling them, and filtering them. Your code is cleaner, easier to read, and best of all, easier to manage.

"use client";

import React, { useReducer, KeyboardEvent } from "react";

type Todo = {
id: number;
text: string;
completed: boolean;
}

type Filter = "all" | "active" | "completed";

type State = {
todos: Todo[];
filter: Filter;
}

type Action =
| { type: "add"; text: string }
| { type: "toggle"; id: number }
| { type: "filter"; filter: Filter };

function todosReducer(state: State, action: Action): State {
switch (action.type) {
case "add":
return {
...state,
todos: [
...state.todos,
{ id: Date.now(), text: action.text, completed: false },
],
};
case "toggle":
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
case "filter":
return {
...state,
filter: action.filter,
};
default:
return state;
}
}

const TodoAppWithReducer: React.FC = () => {
const [{ todos, filter }, dispatch] = useReducer(todosReducer, {
todos: [],
filter: "all",
});

const addTodo = (text: string): void => dispatch({ type: "add", text });
const toggleTodo = (id: number): void => dispatch({ type: "toggle", id });
const setVisibilityFilter = (filter: Filter): void =>
dispatch({ type: "filter", filter });

const getVisibleTodos = (): Todo[] => {
switch (filter) {
case "completed":
return todos.filter((t) => t.completed);
case "active":
return todos.filter((t) => !t.completed);
default:
return todos;
}
};

const visibleTodos = getVisibleTodos();

return (
<div>
<h2>Todos</h2>
<input
type="text"
placeholder="Add todo"
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
addTodo(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
<div>
<button onClick={() => setVisibilityFilter("all")}>All</button>
<button onClick={() => setVisibilityFilter("active")}>Active</button>
<button onClick={() => setVisibilityFilter("completed")}>
Completed
</button>
</div>
<ul>
{visibleTodos.map((todo) => (
<li
key={todo.id}
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.completed ? "line-through" : "none" }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
};

export default TodoAppWithReducer;

Wrapping Up

Starting with useState is fine when your app is small. But as it grows, you'll feel the pain. That's where useReducer comes in, cleaning up the mess and keeping things orderly. It's like going from a cluttered desk to a well-organized workspace where everything is just where you need it. So, when your app starts to get complicated, remember useReducer is your friend for keeping your state management clean and manageable.

Happy coding!

--

--