standel.io

React's useState is the real problem

Ethan Standel 15 min read
Published 3.04.23
React
TypeScript
DX
Signals

Why do I feel like I'm the only person complaining about this?

React's useEffect hook gets all the hate. It's covered in foot-guns and edge cases, and the double-fire behavior is weird in development mode. But for all the hate it gets, I would make the argument that it is a great primitive hook that fulfills a lot of needs for React developers to efficiently perform actions outside of a render to sync React with an outside source. What I don't think gets nearly enough hate is the odd choice of behavior for useState.

Ugly developer ergonomics

The first oddity is the aesthetic choice to return the [value, setValue] tuple.

const [value, setValue] = useState(0);

The implication of having the value and it's setter always being separate would be that there would be utility in utilizing these things separately. On the contrary, that's actually antithetical to the concept of useState and what it is meant to represent. You will always need the value and you will always need the setter. If you don't need the setter, then what you have is probably a derived state that should be calculated based on other pieces of state or props. If you don't need the value then what you have probably isn't really even state.

The tuple API decision inherently leaves it up to the React developer to manage the verbosity of referencing the state's name twice. Because the state and setter are two different things, it makes the process of defining a piece of state more verbose, and it makes the process of passing state downward into the tree more verbose. This isn't a big deal with small components, but when you have a component that requires several pieces of state, it leads to code that looks so ugly that we reflexively think that we have done something wrong in our code.

const ContactForm = () => {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [email, setEmail] = useState("");
  const [phone, setPhone] = useState("");
  const [message, setMessage] = useState("");

  return (
    <form>
      ...
    </form>
  );
};

Looks like a code-smell to me!

The stale state problem

As it turns out, the problems from this decision aren't just aesthetic. The concept of stale state is one of the most common foot-guns in React. It often gets blamed on the challenging API of useEffect, but I would argue that it is the model of useState that is the root cause of this problem. Let's take a look at a simple common mistake for an example.

const Timer = () => {
  const [timer, setTimer] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setTimer(timer + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return (
    <>
      ...
    </>
  );
};

Simple example right? We have a state called timer that should be ticking up every second. It ticks up once and then stops. Why? The timer value is placed into the useEffect but the effect is never updated when timer changes. There's two fixes we can implement from here.

useEffect(() => {
  const interval = setInterval(() => {
    setInterval(timer + 1);
  }, 1000);

  return () => clearInterval(interval);
}, [timer]);

The dependency array fix

useEffect(() => {
  const interval = setInterval(() => {
    setInterval(timer => timer + 1);
  }, 1000);

  return () => clearInterval(interval);
}, []);

The functional update fix

In many cases where we're using useEffect, the recommendation would be to keep the effect handler up to date with the current state by using what I've labeled here as the dependency array fix. However, in this case we would be losing the cadence of setInterval which should be giving us an event every second. If we're constantly updating the useEffect handler, we're going to be constantly clearing and setting the interval, which will give us a much less accurate timer.

To avoid this scenario, React does still offer us a safe way to reliably access the latest state, using functional updates. So the functional update fix solves the problem, right? Sort of. But then the question becomes: What if we need multiple pieces of state? We can still use the functional setter method but it becomes a bit of a hot mess.

const Timer = () => {
  const [timer, setTimer] = useState(0);
  const [timerMultiplier, setTimerMultiplier] = useState(1);

  useEffect(() => {
    const interval = setInterval(() => {
      setTimerMultiplier((timerMultiplier) => {
        setTimer((timer) => {
          return timer + timerMultiplier;
        });
        // return the original value so we don't accidentally setTimerMultiplier to undefined
        return timerMultiplier;
      });
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      <button onClick={() => setTimerMultiplier(timerMultiplier + 1)}>
        Increment timer multiplier
      </button>
      {timer}
    </div>
  );
}

A multi-state functional update timer

I know that the setInterval example is a bit contrived. How many people are deploying code regularly that uses setInterval? It's not a tool that's often used, but I've run into this same problem when building animations using requestAnimationFrame or when performing actions on the window's "scroll" event. Having to stop your requestAnimationFrame recursion or cancel your "scroll" listener every time the state changes is awkward to implement in code and can be performance expensive in these critical moments where dropping a few frames becomes very noticeable.

The useEvent problem

React's useCallback hook was originally implemented to be a great tool to increase the performance of applications by allowing for better memoization and lessening the weight of the diffing & reconciliation process by passing around the same function reference through multiple renders. However, over the last few years the React team has realized that useCallback is incredibly limited in actual implementations.

The problem is that useCallback takes in a dependency array. When any values in the dependency array are updated, so is the reference to the callback returned by useCallback. This often times renders useCallback useless or sometimes a performance detriment due to the extra work it does by comparing the old & new values in the dependency array. So if you have a memoized component that relies on a callback, then whenever that callback updates (which happens any time any of its state dependencies update), your memoized component will now need to rerender. Or if you're passing that callback into the event handler of an intrinsic element, then the diffing algorithm will notice that your callback has changed and will need remove the old event listener and add a new one with your new callback reference which can be expensive depending on how often the associated state updates.

React's solution to this is the upcoming useEvent hook. The useEvent hook takes the callback from every render and passes it into a ref from useRef. Because the actual reference to a ref is maintained and always the same between renders, they can then create a function in a useCallback with no dependencies. The function passed to useCallback will call the function stored in the underlying ref. This allows for a stable function reference to be passed around that always gets up to date state in every render. This is a great solution to the problems of useCallback but ultimately I think it ignores the original source of the problem which is the useState model that causes so much stale state in the first place.

A more ergonomic useState

It occurred to me that the model of the solution of useEvent could be applied directly to state as well. So we could have a stable reference to a traditional piece of state where you can always rely on getting the latest value while also being able to trigger an update. This is what I've called useErgoState in my experimental library of the same name, use-ergo-state. With useErgoState, here's what the setInterval example would look like with multiple states.

const ContactForm = () => {
  const timer = useErgoState(0);
  const timerMultiplier = useErgoState(1);

  useEffect(() => {
    const interval = setInterval(() => {
      timer(timer() + timerMultiplier());
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return (
    <>
      ...
    </>
  );
};

As you can see, useErgoState returns a (stable) function that can both be called to get the true latest value of state or it can be called with a value to set a new value for the state and trigger a rerender. This is a much more ergonomic API than useState as it cuts down on repetition, and makes the concept of stale state almost entirely irrelevant.

Changing the meaning of the dependency array

Since the advent of hooks, the dependency array has been meant to hold any non-stable values that your effect might be utilizing. This means almost anything that comes from state and anything that comes from props that isn't a ref or state setter. And this also means that your useEffect callback will refire when any of these values change and there is a new render. However, there are some times where you only need to run your effect in the case of certain property changes and other pieces of state that are used in the effect don't necessarily need to trigger a rerun.

React's proposed upcoming solution to this would be to recommend that you declare this logic separately using useEffectEvent (similar implementation to useEvent). But the mental model for what separates out "event" logic from stateful "effect" logic is a bit superficial and it's not always clear what should be in the dependency array and what should be in the useEffectEvent handler. If we take this example from the React docs, it seems hard to say that the actions in onConnected are actually "events" separate from the chatroom connection updating.

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...
}

If we just treat state the same way the React team is trying to treat events, we could avoid this complexity almost entirely, which is where useErgoState comes in again. Let's take their example from the docs and see how it would look with useErgoState.

// we're assuming both roomId & theme are both declared as useErgoState
// and the value was passed directly from the hook as props to this component
function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId());
    connection.on('connected', () => {
      showNotification('Connected!', theme());
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId()]); // ✅ We want this effect to rerun when roomId changes so its value is still in the deps array
  // ...
}

With useErgoState, we no longer have to make this strange distinction between "events" and "effects" just to avoid stale state.

Downsides & foot-guns of useErgoState

I genuinely think this solution is better than the current primitive state API from React. However, it's not without it's own challenges and foot-guns. The biggest one is that it's easy to forget to call the function returned from useErgoState to get the latest value. I think using TypeScript will generally help you avoid this error, but it will still trip you up to change your default mindset of "state is a value" to "state is a function that returns a value." However, SolidJS uses a very similar model for it's core state primitive, createSignal, and their community has been happy with this API.

Next, there is a smaller known issue with useErgoState when a developer wants to purposefully set the state to undefined. Because useErgoState returns a function that acts as the state getter when nothing is passed in, passing undefined to the function will fulfill the same behavior: just passing back the existing state without any updates. This issue has two potential remedies. The first option is that you can just use null rather than undefined to represent nil values. The second potential remedy is that the function returned from useErgoState is still able to accept a state-setter function just like React. So you can set someState to undefined by writing someState(() => undefined).

Finally, a lot of the problems with stale state references would still be a problem if state worked as a reference because props are treated the same way. So although useErgoState can solve the stale state problem with state itself, you'll still have to be careful about stale props. The only recommendation I could give is to have components accept instances of { type MutableStateRef } from "use-ergo-state" (the type that useErgoState returns) instead of the actual value itself. This way, you can be sure that the component can always access the latest value of a prop as the prop itself is just a reference. This solution, I realize, is definitely a harder sell as it more aggressively changes the way we think about writing React applications. So maybe a more realistic solution is a combination of the new way and the old ways. It's not like we could ever truly eliminates values from our code, but I think we could do more to avoid them.

Conclusions

With all the issues I've brought to the table, I think it's worth considering if the current model for useState is worth holding on to with the risks complexities, and performance detriments it introduces. It seems like a lot of the brain power on the React team is going towards monkey-patching around the performance & scalability limitations caused by constantly stale state. Alongside that, the tuple API has always read awkward and verbose to me. I would love to hear people's thoughts on this subject, and if you have any real world examples where the current API has worked for you in a way that a ref-based API just wouldn't. I would appreciate it if you would try out the use-ergo-state package, or if you don't want to add a new package to your repo, feel free to copy the source code as a custom hook into your project's src/hooks to try it out whenever you find the behavior of React's useState appears to be non-optimal for a scenario. It's under 50 LOC at the time of writing this, so I would understand if you feel it's not worth the associated risk with pulling in a package.

Bonus: Signals!

Signals could also be the solution!

There's been a lot of discussion about signals lately, and it seems awkward for me to criticize React's defacto state primitive without discussing it's leading competition in the front-end ecosystem. When signals are brought up as a superior solution to state management, the topic has a few branching paths. One of the branching topics that is said to be superior with signals is the fact that every existing signal implementation is ref based just like useErgoState and so it solves issues like the setInterval problem just as well.

Signals don't have to be the solution!

So why useErgoState rather than an existing signal-based solution for React? Well using signals creates a lot of other complexities about how React should function. For instance, one of the nicest things about how React functions is how it expects you to deal with state that is derived from other states. React wants you to treat every render like it's the first render, and so their recommendation is that you run those calculations inline, in the render. This is stated well in the article "You might not need an effect" in React's beta documentation.

// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
As opposed to
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;

With Signals, this kind of pattern becomes slightly less ergonomic. In signal based architectures, you component function doesn't rerun with every state update. So all state which is derived from other state should be declared, usually, as a computed signal. Preact's @preact/signals-react is a signal implementation for React and it's model allows you to conflate a signal as a state value that causes a VDOM rerender as well as something that can be plugged directly into the DOM. Preact's model is powerful because it allows you to declare your signals in such a way that they do act as state that requires rerenders (if you access the value of your signal directly in a component), or they can just act as signals whose updates are directly injected into the DOM. However, that can lead to inconsistent behavior where sometimes you need to create derived state using their computed function, or you can do inline derived state depending on how the signal is used. In SolidJS it's a little more cut and dry, as you can declare one-off computed signals as functions, but generally you should use their createComputed function. I think for a lot of developers, these trades are not deal-breakers, but it is the DX that React has always intended to solve with it's rendering model.

So that leaves useErgoState somewhere in-between the advantages of a signal-based architecture and the ease of React's component model which I think justifies its existence just a little, but also likely why this API model would never really take off or be adopted by React.