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 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.
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