Were React Hooks a Mistake?

The web dev community has spent the past few weeks buzzing about signals, a reactive programming pattern that enables very efficient UI updates. Devon Govett wrote a thought-provoking Twitter thread about signals and mutable state Devon Govett on X Easy to forget, but the debate about signals is the same one we had about 2-way data binding vs unidirectional data flow 10 years ago. Signals are mutable state! They’re bindings with a new name. The simplicity of UI as a function of state is lost when updates flow unpredictably. twitter.com/devongovett/status/1629540226589663233 . Ryan Carniato responded with an excellent article comparing signals with React React vs Signals: 10 Years Later How does the old Winston Churchill quote go? Those who fail to learn from history are doomed to... dev.to/this-is-learning/react-vs-signals-10-years-later-3k71 . Good arguments on all sides; the discussion has been really interesting to watch.

One thing the discourse makes clear is that there are a lot of people for whom the React programming model just does not click. Why is that?

I think the issue is that people’s mental model of components doesn’t match how function components with hooks work in React. I’m going to make an audacious claim: people like signals because signal-based components are far more similar to class components than to function components with hooks.


Let’s rewind a bit. React components used to look like this:1

class Game extends React.Component {
  state = { count: 0, started: false };

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  start() {
    if (!this.state.started) setTimeout(() => alert(`Your score was ${this.state.count}!`), 5000);
    this.setState({ started: true });
  }

  render() {
    return (
      <button
        onClick={() => {
          this.increment();
          this.start();
        }}
      >
        {this.state.started ? "Current score: " + this.state.count : "Start"}
      </button>
    );
  }
}

Each component was an instance of the class React.Component. State was kept in the property state, and callbacks were just methods on the instance. When React needed to render a component, it would call the render method.

You can still write components like this. The syntax hasn’t been removed. But back in 2015, React introduced something new: stateless function components React v0.14 – React Blog This blog site has been archived. Go to react.dev/blog to see the recent posts. We’re happy to announce the release of React 0.14 today! This release has a few major changes, primarily designed to simplify the code you write every day and to better support environments like React Native. If you tried the release candidate, thank you – your support is invaluable and we’ve fixed a few bugs that you reported. As with all of our releases, we consider this version to be stable enough to use in… reactjs.org/blog/2015/10/07/react-v0.14.html .

function CounterButton({ started, count, onClick }) {
  return <button onClick={onClick}>{started ? "Current score: " + count : "Start"}</button>;
}

class Game extends React.Component {
  state = { count: 0, started: false };

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  start() {
    if (!this.state.started) setTimeout(() => alert(`Your score was ${this.state.count}!`), 5000);
    this.setState({ started: true });
  }

  render() {
    return (
      <CounterButton
        started={this.state.started}
        count={this.state.count}
        onClick={() => {
          this.increment();
          this.start();
        }}
      />
    );
  }
}

At the time, there was no way to add state to these components — it had to be kept in class components and passed in as props. The idea was that most of your components would be stateless, powered by a few stateful components near the top of the tree.

When it came to writing the class components, though, things were… awkward. Composition of stateful logic was particularly tricky. Say you needed multiple different classes to listen for window resize events. How would you do that without rewriting the same instance methods in each one? What if you needed them to interact with the component state? React tried to solve this problem with mixins React Without ES6 – React A JavaScript library for building user interfaces reactjs.org/docs/react-without-es6.html#mixins , but they were quickly deprecated Mixins Considered Harmful – React Blog This blog site has been archived. Go to react.dev/blog to see the recent posts. “How do I share the code between several components?” is one of the first questions that people ask when they learn React. Our answer has always been to use component composition for code reuse. You can define a component and use it in several other components. It is not always obvious how a certain pattern can be solved with composition. React is influenced by functional programming but it came into the field that… reactjs.org/blog/2016/07/13/mixins-considered-harmful.html once the team realized the drawbacks.

Also, people really liked function components! There were even libraries for adding state to them GitHub - acdlite/recompose: A React utility belt for function components and higher-order components. A React utility belt for function components and higher-order components. - GitHub - acdlite/recompose: A React utility belt for function components and higher-order components. github.com/acdlite/recompose . So perhaps it’s not surprising that React came up with a built-in solution: hooks.

function Game() {
  const [count, setCount] = useState(0);
  const [started, setStarted] = useState(false);

  function increment() {
    setCount(count + 1);
  }

  function start() {
    if (!started) setTimeout(() => alert(`Your score was ${count}!`), 5000);
    setStarted(true);
  }

  return (
    <button
      onClick={() => {
        increment();
        start();
      }}
    >
      {started ? "Current score: " + count : "Start"}
    </button>
  );
}

When I first tried them out, hooks were a revelation. They really did make it easy to encapsulate behavior and reuse stateful logic. I jumped in headfirst; the only class components I’ve written since then have been error boundaries.

That said — although at first glance this component works the same as the class component above, there’s an important difference. Maybe you’ve spotted it already: your score in the UI will be updated, but when the alert shows up it’ll always show you 0. Because setTimeout only happens in the first call to start, it closes over the initial count value and that’s all it’ll ever see.

You might think you could fix this with useEffect:

function Game() {
  const [count, setCount] = useState(0);
  const [started, setStarted] = useState(false);

  function increment() {
    setCount(count + 1);
  }

  function start() {
    setStarted(true);
  }

  useEffect(() => {
    if (started) {
      const timeout = setTimeout(() => alert(`Your score is ${count}!`), 5000);
      return () => clearTimeout(timeout);
    }
  }, [count, started]);

  return (
    <button
      onClick={() => {
        increment();
        start();
      }}
    >
      {started ? "Current score: " + count : "Start"}
    </button>
  );
}

This alert will show the correct count. But there’s a new issue: if you keep clicking, the game will never end! To prevent the effect function closure from getting “stale”, we add count and started to the dependency array. Whenever they change, we get a new effect function that sees the updated values. But that new effect also sets a new timeout. Every time you click the button, you get a fresh five seconds before the alert shows up.

In a class component, methods always have access to the most up-to-date state because they have a stable reference to the class instance. But in a function component, every render creates new callbacks that close over its own state. Each time the function is called, it gets its own closure. Future renders can’t change the state of past ones.

Put another way: class components have a single instance per mounted component, but function components have multiple “instances” — one per render. Hooks just further entrench that constraint. It’s the source of all your problems with them:

  • Each render creates its own callbacks, which means anything that checks referential equality before running side effects — useEffect and its siblings — will get triggered too often.
  • Callbacks close over the state and props from their render, which means callbacks that persist between renders — because of useCallback, asynchronous operations, timeouts, etc — will access stale data.

React gives you an escape hatch to deal with this: useRef, a mutable object that keeps a stable identity between renders. I think of it as a way to teleport values back and forth between different instances of the same mounted component. With that in mind, here’s what a working version of our game might look like using hooks:

function Game() {
  const [count, setCount] = useState(0);
  const [started, setStarted] = useState(false);
  const countRef = useRef(count);

  function increment() {
    setCount(count + 1);
    countRef.current = count + 1;
  }

  function start() {
    if (!started) setTimeout(() => alert(`Your score was ${countRef.current}!`), 5000);
    setStarted(true);
  }

  return (
    <button
      onClick={() => {
        increment();
        start();
      }}
    >
      {started ? "Current score: " + count : "Start"}
    </button>
  );
}

It’s pretty kludgy! We’re now tracking the count in two different places, and our increment function has to update them both. The reason that it works is that every start closure has access to the same countRef; when we mutate it in one, all the others can see the mutated value as well. But we can’t get rid of useState and only rely on useRef, because changing a ref doesn’t cause React to re-render. We’re stuck between two different worlds — the immutable state that we use to update the UI, and the mutable ref with the current state.

Class components don’t have this drawback. The fact that each mounted component is an instance of the class gives us a kind of built-in ref. Hooks gave us a much better primitive for composing stateful logic, but it came at a cost.


Although they’re not new The Evolution of Signals in JavaScript There has been some buzz recently in the frontend world around the term "Signals". In seemingly short... dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob , signals have experienced a recent explosion in popularity, and seem to be used by most major frameworks other than React.

The usual pitch is that they enable “fine-grained reactivity”. That means when state changes, they only update the specific pieces of the DOM that depend on it. For now, that usually ends up being faster than React, which recreates the full VDOM tree before discarding the parts that don’t change. But to me, these are all implementation details. People aren’t switching to these frameworks just for the performance. They’re switching because these frameworks offer a fundamentally different programming model.

If we rewrite our little counter game with Solid, for example, we get something that looks like this:

function Game() {
  const [count, setCount] = createSignal(0);
  const [started, setStarted] = createSignal(false);

  function increment() {
    setCount(count() + 1);
  }

  function start() {
    if (!started()) setTimeout(() => alert(`Your score was ${count()}!`), 5000);
    setStarted(true);
  }

  return (
    <button
      onClick={() => {
        increment();
        start();
      }}
    >
      {started() ? "Current score: " + count() : "Start"}
    </button>
  );
}

It looks almost identical to the first hooks version! The only visible differences are that we’re calling createSignal instead of useState, and that count and started are functions we call whenever we want to access the value.2 As with class and function components, though, the appearance of similarity belies an important difference.

The key with Solid and other signal-based frameworks is that the component is only run once, and the framework sets up a data structure that automatically updates the DOM when signals change. Only running the component once means we only have one closure. Having only one closure gives us a stable instance per mounted component again, because closures are equivalent to classes.

What?

It’s true!3 Fundamentally, they’re both just bundles of data and behavior. Closures are primarily behavior (the function call) with associated data (closed over variables), while classes are primarily data (the instance properties) with associated behavior (methods). If you really wanted to, you could write either one in terms of the other.

Think about it. With class components…

  • The constructor sets up everything the component needs to render (setting initial state, binding instance methods, etc).
  • When you update the state, React mutates the class instance, calls the render method and makes any necessary changes to the DOM.
  • All functions have access to up-to-date state stored on the class instance.

Whereas with signals components…

  • The function body sets up everything the component needs to render (setting up the data flow, creating DOM nodes, etc).
  • When you update a signal, the framework mutates the stored value, runs any dependent signals and makes any necessary changes to the DOM.
  • All functions have access to up-to-date state stored in the function closure.

From this point of view, it’s a little easier to see the tradeoffs. Like classes, signals are mutable. That might seem a little weird. After all, the Solid component isn’t assigning anything — it’s calling setCount, just like React! But remember that count isn’t a value itself — it’s a function that returns the current state of the signal. When setCount is called, it mutates the signal, and further calls to count() will return the new value.

Although Solid’s createSignal looks like React’s useState, signals are really more like refs: stable references to mutable objects. The difference is that in React, which is built around immutability, refs are an escape hatch that has no effect on rendering. But frameworks like Solid put signals front and center. Rather than ignoring them, the framework reacts when they change, updating only the specific parts of the DOM that use their values.

The big consequence of this is that the UI is no longer a pure function of state. That’s why React embraces immutability: it guarantees that the state and UI are consistent. When mutations are introduced, you also need a way to keep the UI in sync. Signals promise to be a reliable way to do that, and their success will hinge on their ability to deliver on that promise.


To recap:

  1. First we had class components, which kept state in a single instance shared between renders.
  2. Then we had function components with hooks, in which each render had its own isolated instance and state.
  3. Now we’re swinging toward signals, which keep state in a single instance again.

So were React hooks a mistake? They definitely made it easier to break up components and reuse stateful logic.4 Even as I type this, if you were to ask me whether I’ll be abandoning hooks and returning to class components, I’d tell you no.

At the same time, it’s not lost on me that the appeal of signals is regaining what we already had with class components. React made a big bet on immutability, but people have been looking for ways to have their cake and eat it too for a while now. That’s why libraries like immer Introduction to Immer | Immer Immer (German for: always) is a tiny package that allows you to work with immutable state in a more convenient way. immerjs.github.io/immer/ and MobX MobX mobx.js.org exist: it turns out that the ergonomics of working with mutable data can be really convenient.

People seem to like the aesthetics of function components and hooks, though, and you can see their influence in newer frameworks. Solid’s createSignal SolidJS Solid is a purely reactive library. It was designed from the ground up with a reactive core. It's influenced by reactive principles developed by previous libraries. www.solidjs.com/tutorial/introduction_effects is almost identical to React’s useState. Preact’s useSignal Signals – Preact Guide Signals: composable reactive state with automatic rendering preactjs.com/guide/v10/signals/#local-state-with-signals is similar as well. It’s hard to imagine that these APIs would look like they do without React having lead the way.

Are signals better than hooks? I don’t think that’s the right question. Everything has tradeoffs, and we’re pretty sure about the tradeoffs signals make: they give up immutability and UI as a pure function of state, in exchange for better update performance and a stable, mutable instance per mounted component.

Time will tell whether signals will bring back the problems React was created to fix. But right now, frameworks seem to be trying to strike a sweet spot between the composability of hooks and the stability of classes. At the very least, that’s an option worth exploring.

Footnotes

  1. Even before this, we had React.createClass Displaying Data | React A JavaScript library for building user interfaces web.archive.org/web/20160212215108/http://facebook.github.io/react/docs/displaying-data.html . We don’t speak of those days.

  2. Idiomatic Solid actually uses a Show component Conditional User Interface Display docs.solidjs.com/guides/tutorials/getting-started-with-solid/control-flow#conditionally-showing-content for conditional UI.

  3. Technically, they’re equivalent to class instances — AKA objects. Some people say “closures are a poor man’s objects”, and vice versa wiki.c2.com/?ClosuresAndObjectsAreEquivalent .

  4. Although I do wonder what React might have looked like if it leaned further into the class paradigm and implemented something like the component pattern in game development Component · Decoupling Patterns · Game Programming Patterns gameprogrammingpatterns.com/component.html .