DEV Community

Cover image for A cure for React useState hell?
Steve Sewell for Builder.io

Posted on • Edited on • Originally published at builder.io

A cure for React useState hell?

Do you ever find yourself in React useState hook hell?

Yeah, this good stuff:

import { useState } from "react";

function EditCalendarEvent() {
  const [startDate, setStartDate] = useState();
  const [endDate, setEndDate] = useState();
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [location, setLocation] = useState();
  const [attendees, setAttendees] = useState([]);

  return (
    <>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      {/* ... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The above is a component to update a calendar event. Sadly, it has a number of problems.

Besides not being easy on the eyes, there are no safeguards here. There’s nothing preventing you from choosing an end date that’s before the start date, which makes no sense.

There’s also no guard for a title or description that is too long.

Sure, we could hold our breath and trust that everywhere we’re calling set*() we will remember (or even know) to validate all of these things before writing to state, but I don’t rest easy knowing things are just sitting in such an easy to break state.

There is a more powerful alternative to useState

Did you know that there is an alternative state hook that is more powerful and easier to use than you might think?

Using useReducer, we could transform the above code, to just this:

import { useReducer } from "react";

function EditCalendarEvent() {
  const [event, updateEvent] = useReducer(
    (prev, next) => {
      return { ...prev, ...next };
    },
    { title: "", description: "", attendees: [] }
  );

  return (
    <>
      <input
        value={event.title}
        onChange={(e) => updateEvent({ title: e.target.value })}
      />
      {/* ... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The useReducer hook helps you control transformations from state A to state B.

Now, you could say “I can do that with useState too, see” and point to some code like this:

import { useState } from "react";

function EditCalendarEvent() {
  const [event, setEvent] = useState({
    title: "",
    description: "",
    attendees: [],
  });

  return (
    <>
      <input
        value={event.title}
        onChange={(e) => setEvent({ ...event, title: e.target.value })}
      />
      {/* ... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Though you’d be right, there’s one very important thing to consider here. Besides that this format still hopes that you always remember to spread on ...event so you don’t mess up by mutating the object directly (and subsequently causing React to not rerender as expected), it still misses out on the most critical benefit of useReducer — the ability to supply a function that controls state transitions.

Going back to using useReducer, the only difference is you get an additional argument that is a function that can help us ensure that each state transition is safe and valid:

  const [event, updateEvent] = useReducer(
    (prev, next) => {
      // Validate and transform event to ensure state is always valid
      // in a centralized way
      // ...
    },
    { title: "", description: "", attendees: [] }
  );
Enter fullscreen mode Exit fullscreen mode

This has the major benefit of guaranteeing that your state is always valid, in a fully centralized way.

So with this model, even if future code is added over time, and future developers on your team call updateEvent() with potentially invalid data, your callback will always fire.

For instance, we may want to always ensure, no matter how and where state is written, that the end date is never before the start date (as that would make no sense), and that the title has a max length of 100 characters:

import { useReducer } from "react";

function EditCalendarEvent() {
  const [event, updateEvent] = useReducer(
    (prev, next) => {
      const newEvent = { ...prev, ...next };

      // Ensure that the start date is never after the end date
      if (newEvent.startDate > newEvent.endDate) {
        newEvent.endDate = newEvent.startDate;
      }

      // Ensure that the title is never more than 100 chars
      if (newEvent.title.length > 100) {
        newEvent.title = newEvent.title.substring(0, 100);
      }
      return newEvent;
    },
    { title: "", description: "", attendees: [] }
  );

  return (
    <>
      <input
        value={event.title}
        onChange={(e) => updateEvent({ title: e.target.value })}
      />
      {/* ... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This ability to prevent direct mutation of state gives us a major safety net, especially as our code grows in complexity over time.

Note that you should also provide validation in your UI as well. But think of this as an additional set of safety guarantees, a bit like an ORM over a database, so that we can be fully assured that our state is always valid when written. This can help us prevent strange and hard to debug issues in the future.

You can use useReducer virtually anywhere you’d use useState

Maybe you have the world's simplest component, a basic counter, so you are using the useState hook:

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

But even in this small example, should count be able to go infinitely high? Should it ever be negative?

Ok, maybe it’s not so conceivable how a negative value could be achieved here, but if we wanted to set a count limit, it’s trivial with useReducer and we can have complete confidence that the state is always valid, regardless of where and how it’s written to.

import { useReducer } from "react";

function Counter() {
  const [count, setCount] = useReducer((prev, next) => Math.min(next, 10), 0);

  return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

(Optionally) Redux-ify things

As things get more complex, you could even opt to use a redux style action-based pattern as well.

Going back to our calendar event example, we could alternatively write it similar to the below:

import { useReducer } from "react";

function EditCalendarEvent() {
  const [event, updateEvent] = useReducer(
    (state, action) => {
      const newEvent = { ...state };

      switch (action.type) {
        case "updateTitle":
          newEvent.title = action.title;
          break;
        // More actions...
      }
      return newEvent;
    },
    { title: "", description: "", attendees: [] }
  );

  return (
    <>
      <input
        value={event.title}
        onChange={(e) => updateEvent({ type: "updateTitle", title: "Hello" })}
      />
      {/* ... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you look up just about any docs or articles about useReducer, they seem to imply this is the one and only way to use the hook.

But I want to impress upon you that this is just only one of many patterns you can use this hook for. While this is subjective, I am personally not a huge fan of Redux and this type of pattern. It has its merits, but I think once you want to start layering in new patterns for actions, Mobx, Zustand, or XState are preferable in my personal opinion.

That said, there is something elegant about being able to utilize this pattern without any additional dependencies, so I’ll give people who love this format that.

Sharing reducers

One other nicety of useReducer is it can be handy when child components need to update data managed by this hook. As opposed to having to pass several functions like you would when using useState, you could just pass your reducer function down.

From an example in the React docs:

const TodosDispatch = React.createContext(null);

function TodosApp() {
  // Note: `dispatch` won't change between re-renders
  const [todos, updateTodos] = useReducer(todosReducer);

  return (
    <TodosDispatch.Provider value={updateTodos}>
      <DeepTree todos={todos} />
    </TodosDispatch.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then from the child:

function DeepChild(props) {
  // If we want to perform an action, we can get dispatch from context.
  const updateTodos = useContext(TodosDispatch);

  function handleClick() {
    updateTodos({ type: "add", text: "hello" });
  }

  return <button onClick={handleClick}>Add todo</button>;
}
Enter fullscreen mode Exit fullscreen mode

This way you can not only have just one unified update function, but have safety guarantees that state updates triggered by child components conform to your requirements.

The common pitfall

It is important to keep in mind that you must always treat the state value of the useReducer hook as immutable. A number of problems can occur if you accidentally mutate the object in your reducer function.

For instance, an example from the React docs:

function reducer(state, action) {
  switch (action.type) {
    case "incremented_age": {
      // 🚩 Wrong: mutating existing object
      state.age++;
      return state;
    }
    case "changed_name": {
      // 🚩 Wrong: mutating existing object
      state.name = action.nextName;
      return state;
    }
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

and the fix:

function reducer(state, action) {
  switch (action.type) {
    case "incremented_age": {
      // ✅ Correct: creating a new object
      return {
        ...state,
        age: state.age + 1,
      };
    }
    case "changed_name": {
      // ✅ Correct: creating a new object
      return {
        ...state,
        name: action.nextName,
      };
    }
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

If you find you are often running into this problem, you could enlist the help of a small library:

(Optional) Common pitfall solution: Immer

One very nifty library for ensuring immutable data, while having an elegant mutable DX, is Immer.

The use-immer package additionally provides a useImmerReducer ****function that allows you to make state transitions via direct mutation, but under the hood creates an immutable copy using Proxies in JavaScript.

import { useImmerReducer } from "use-immer";

function reducer(draft, action) {
  switch (action.type) {
    case "increment":
      draft.count++;
      break;
    case "decrement":
      draft.count--;
      break;
  }
}

function Counter() {
  const [state, dispatch] = useImmerReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This of course is completely optional, and only needed if this solves problems you are actively encoutering.

So when should I use useState vs useReducer?

Despite my enthusiasm for useReducer, and pointing out the many places you *could*** use it, let’s remember not to prematurely abstract.

In general, you are likely still fine using useState. I would consider incrementally adopting useReducer as your state and validation requirements begin to get more complex, warranting the additional effort.

Then, if you are adopting useReducer for complex objects frequently, and often hit pitfalls around mutation, introducing Immer could be worthwhile.

That, or if you’ve gotten to this point of state management complexity, you may want to look at some even more scalable solutions, such as Mobx, Zustand, or XState to meet your needs.

But don’t forget, start simple, and add complexity only as needed.

Thank you, David

This post was inspired by David Khourshid, creator of XState as Stately, who opened my eyes to the possibilities of the useReducer hook with his epic twitter thread:

About Me

Hi! I'm Steve, CEO of Builder.io.

We make a way to drag + drop with your components to create pages and other CMS content on your site or app, visually.

You may find it interesting or useful:

Gif of Builder.io

Top comments (29)

Collapse
 
manot40 profile image
manot40

This post literally changed my life

Collapse
 
dikamilo profile image
dikamilo

It's bad designed anyway. This EditCalendarEvent component, have a lot of responsibility. Having 6 input fields, you will re-render everything every time you change any of that fields because you share state and useEffect or useReducer doesn't matter here.

Collapse
 
brense profile image
Rense Bakker

Rerender is ignored for the other 5 fields if their values didnt change, the props will be the same and it will keep the DOM elements that are already there for those fields.

How would you suggest to make a component to edit some data model, like a calendar event, that has multiple fields (e.g. name, date, description, etc.)? It's a pretty common case to have modals like these with multiple input fields and the solution suggested by the author is pretty good imho.

Collapse
 
dikamilo profile image
dikamilo • Edited

EditCalendarEvent components holds global state as single states with useState or as single object with useReducer, so it will re render whole component on each change. With useReducer new object is returned so it have same component update behaviour as using multiple useState. Only one change is here that you have validation logic in one place.

You can create separate components for inputs that will hold own state or use refs instead of state for that data.

A lot of form don't require that anyway, you can just get form data in onSubmit callback like so:

onSubmit={(e) => submitForm(new FormData(e.currentTarget))
Enter fullscreen mode Exit fullscreen mode

useReducer is nice for things that changes together, form independent input fields in form, not so much.

Thread Thread
 
brense profile image
Rense Bakker

I dont think you can consider the event data as global state, its bound to the specific form component. You have to consider the case here that you are fetching the event data from a database (when editing for example). You are not going to do individual database queries for each field, so you need some way to pass down the event data to the individual input components.

There is no issue with this aslong as you keep the values referentially equal, so React is able to determine that the props/state for the other inputs didnt actually change. If React can determine that the props/state did not change, it will not make any DOM changes for those components.

The example by the author with the useReducer hook, is a good/clean way to achieve this.

Thread Thread
 
dikamilo profile image
dikamilo

I dont think you can consider the event data as global state, its bound to the specific form component.

Yes, I mean "global data" in context of this specific components tree.

Collapse
 
wiseai profile image
Mahmoud Harmouch • Edited

Great share! However, you can also achieve the same behavior with useState using a curried function ( also with the ability to supply a function that controls state transitions ):

import { useState } from "react";

function EditCalendarEvent() {
  const [event, setEvent] = useState({
    title: "",
    description: "",
    attendees: [],
  });

  const handleChange = (prop) => (e) => {
    // Validate and transform event to ensure state is always valid
    // in a centralized way
    // ...
    setEvent({ ...event, [prop]: e.target.value });
  };

  return (
    <>
      <input
        value={event.title}
        onChange={(e) => handleChange("title")(e)}
      />
      {/* ... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
kamtoeddy profile image
Kamto Eddy

I've done this before but slightly different

const handleChange = (prop) => (e) => {
    // Validate and transform event to ensure state is always valid
    // in a centralized way
    // ...
    setEvent(event => ({ ...event, [prop]: e.target.value }));
  };
Enter fullscreen mode Exit fullscreen mode

Just to be sure I get the most up-to-date state

Collapse
 
shareef profile image
Mohammed Nadeem Shareef

I also do in this way. Just adding a point, instead of

setEvent(event => ({ ...event, [prop]: e.target.value }));

do

setEvent(prevEvent => ({ ...prevEvent, [prop]: e.target.value }));

It's not much of a difference but you would have a better code readability.

Collapse
 
fyodorio profile image
Fyodor • Edited

— A cure for React useState hell?
— Angular!
😅

If seriously, great post, thank you 👍

Collapse
 
brunoenribeiro profile image
Bruno Ribeiro

How so? Serious question here. I've been away from Angular for some time, I'd be really pleased to know how Angular deals with this nowadays.

Collapse
 
tami1901 profile image
Tamara Lužija

Until now, I have always avoided useReducer, but after your explanation, I won't anymore. Thank you:)

Collapse
 
mariamarsh profile image
Maria 🍦 Marshmallow

I believe some excellent points have been made in this post. I particularly like the section where you discuss your thoughts on cases where using useState or useReducer, leaves a lot to be considered!

Collapse
 
gabizz profile image
Gabriel Claudiu Maftei • Edited

Great and clear explanation!
You are so true, each and every piece of example found online implies the switch part, making it somehow more difficult to understand the whole pattern.

This, combined with React context is for some time now, my everyday state-management solution. Simple and clear!

Thank you for clearing things up a little for everyone!
Greetings from Romania!

PS: this article should be in the official react.js documentation :)

Collapse
 
michal1024 profile image
Michal Ciesielski

I never thought of using dispatch function to carry updated state values instead of actions - this is really cool!

Collapse
 
nikitababko profile image
Nikita Babko

Great post, thank you 👍

Collapse
 
sanjeevkse profile image
sanjeev shetty

This is what happens, when we are not limited in our mind for thinking, experimenting and exploring possiblilities to simplify things.
Awesome post.