4

Were React Hooks a Mistake?

 1 year ago
source link: https://jakelazaroff.com/words/were-react-hooks-a-mistake/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Were React Hooks a Mistake?

March 5, 2023

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. Ryan Carniato responded with an excellent article comparing signals with React. 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 signals-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.

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, but they were quickly deprecated once the team realized the drawbacks.

Also, people really liked function components! There were even libraries for adding state to them. 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, 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. 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!2 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.3 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 and MobX 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 is almost identical to React’s useState. Preact’s useSignal 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.

Like what you read? Follow along by subscribing to my RSS feed , or search for @[email protected] in any Fediverse app (such as Mastodon ).


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK