Original article link: Were React Hooks a Mistake?
The web development community has been talking about signals in recent weeks, a reactive programming pattern that allows for very efficient UI updates. Devon Govett wrote a thought-provoking Twitter thread discussing signals and mutable state. Ryan Carniato responded with an excellent article comparing signals to
One thing the discussion indicates is that many people are not adapting well to the React programming model. Why is that?
I believe the issue lies in the mismatch between people's mental model of components and how functional components using hooks work in React. I want to make a bold statement: people prefer signals because signal-based components are more similar to class components than functional components using hooks.
Let's rewind a bit. React components used to look like this:
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 is an instance of the React.Component class. State is stored in the state property, and callbacks are just methods on the instance. When React needs to render a component, it calls the render method.
You can still write components like this. The syntax hasn't been removed. However, 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 that time, these components did not have a way to add state—they had to be kept in class components and passed as props. The idea was that most components were stateless, supported by some stateful components near the top of the tree.
However, writing class components was a bit awkward. The composition of stateful logic was particularly tricky. For example, say you needed multiple different classes to listen to window resize events. What if you needed them to interact with component state? React tried to solve this with mixins, but the team quickly realized the downsides.
Moreover, people really liked functional components! There were even libraries to add state to them. So it’s perhaps not surprising that React proposed 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 using hooks, they were truly a revelation. They made it easy to encapsulate behavior and reuse stateful logic. I dove in without hesitation; the only class component I’ve written since then is an error boundary.
That said—despite this component looking the same as the class component above at first glance, there’s an important difference. Perhaps you’ve noticed: the score in the UI will update, but when the alert appears, it will always show 0. Because the setTimeout only happens the first time start is called, and it closes over the initial count value, it can only see that value.
You might think you could use useEffect to solve this problem:
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 problem: if you keep clicking, the game never ends! To prevent the effect function closure from becoming “stale,” we added count and started to the dependency array. Every time they change, we get a new effect function that sees the updated values. But this new effect also sets a new timeout. Each time you click the button, you get a fresh five seconds before the alert appears.
In class components, methods always have access to the latest state because they have a stable reference to the class instance. However, in functional components, each render creates a new callback that closes over its own state. Each time that function is called, it gets its own closure. Future renders cannot change the state of past renders.
In other words: class components have only one instance for each mounted component, but functional components have multiple “instances”—one is created for each render. Hooks further reinforce this constraint. This is the root of all the problems you encounter when using them:
- Each render creates its own callback function, which means anything that checks for reference equality before running side effects—like useEffect and its siblings—will trigger too frequently.
- Callback functions close over the state and props from their render, meaning callbacks that persist between multiple renders due to useCallback, async operations, timeouts, etc., will access stale data.
React gives you an escape hatch for this situation: useRef, which is a mutable object that maintains a stable identity between renders. I think of it as a way to pass values back and forth between different instances of the same mounted component. With this idea, here’s what our game using hooks might look like:
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>
)
}
This is cumbersome! We now have to track the count in two different places, and our increment function must update both. The reason it works is that each start closure can access the same countRef; when we change it in one closure, all other closures can see the changed value. But we can’t get rid of useState and only rely on useRef because changing the reference doesn’t cause React to re-render. We’re stuck between two different worlds—immutable state for updating the UI and mutable references with the current state.
Class components don’t have this drawback. Each mounted component is a class instance, giving us a built-in way to reference. Hooks provide us with the infrastructure needed to better compose stateful logic, but it comes at a cost.
If we were to rewrite our little counter game using Solid, it might look 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 difference is that we call createSignal instead of useState, and count and started are functions we call whenever we want to access their values. However, just like class components and functional components, the apparent similarity masks an important difference.
The key with Solid and other signal-based frameworks is that components run only once, and the framework sets up a data structure that automatically updates the DOM when signals change. Running components only once means we have only one closure. Having only one closure again provides a stable instance for each mounted component, as closures are equivalent to classes.
What?
It’s true! Fundamentally, they are just bundles of data and behavior. Closures are primarily behavior (function calls) with associated data (closed-over variables), while classes are primarily data (instance properties) with associated behavior (methods). If you really wanted to, you could write one using the other.
Think about it. With class components...
- The constructor sets up everything needed for the component to render (initial state, binding instance methods, etc.).
- When you update state, React changes the class instance, calls the render method, and makes any necessary changes to the DOM.
- All functions can access the latest state stored on the class instance.
And with signal components...
- The function body sets up everything needed for the component to render (setting up data flows, creating DOM nodes, etc.).
- When you update a signal, the framework changes the stored value, runs any dependent signals, and makes any necessary changes to the DOM.
- All functions can access the latest state stored in the function closure.
From this perspective, it’s easier to see the trade-offs. Like classes, signals are mutable. This might seem a bit strange. After all, Solid components don’t allocate anything—they call setCount, just like React! But remember, count is not a value itself—it’s a function that returns the current state of the signal. When setCount is called, it changes the signal, and further calls to count() will return the new value.
While Solid’s createSignal looks like React’s useState, signals are actually more like references: stable references to mutable objects. The difference is that in React, built around immutability, references are an escape hatch that has no effect on rendering. But frameworks like Solid prioritize signals. The framework doesn’t ignore them; it reacts to their changes and updates only the specific parts of the DOM that use their values.
The significant consequence of this is that the UI is no longer a pure function of state. This is why React embraced immutability: it guarantees that state and UI are consistent. When mutation is introduced, you also need a way to keep the UI in sync. Signals promise to be a reliable way to achieve this, and whether they succeed or fail depends on how well they fulfill that promise.
In summary:
- First, we had class components that retained state in a single instance shared between renders.
- Then, we had function components with hooks, where each render has its own isolated instance and state.
- Now, we are turning to signals, which again retain state in a single instance.
So, are React hooks a mistake? They certainly made it easier to decompose components and reuse stateful logic. Even as I type these words, if you asked me if I would give up hooks and return to class components, I would tell you no.
At the same time, I also realize that the appeal of signals lies in regaining the capabilities we had when using class components. React made a bold attempt at immutability, but people have been looking for ways to maintain data immutability while still being convenient to work with. That’s why libraries like immer and MobX exist: it turns out that using mutable data can lead to a very convenient user experience.
Are signals better than hooks? I don’t think that’s the right question. Everything has trade-offs, and the trade-offs we make with signals are quite clear: they give up the immutability of state and the UI as a pure function in exchange for better update performance and stable, mutable instances for each mounted component.
Time will tell whether signals will bring back the issues React created to solve. But for now, the frameworks seem to be trying to find a comfortable point between the composability of hooks and the stability of classes. At the very least, it’s an option worth exploring.