I started working with React professionally about three years ago. This was, coincidentally, right around when React Hooks came out. I was working in a codebase with a lot of class components, which always felt clunky.
Let’s take the following example: a counter that increments every second.
Featured Content Ads
add advertising hereclass Counter extends React.Component
constructor()
super();
this.state = count: 0 ;
this.increment = this.increment.bind(this);
increment()
this.setState( count: this.state.count + 1 );
componentDidMount()
setInterval(() =>
this.increment();
, 1000);
render()
return div>The count is: this.state.countdiv>;
That’s a lot of code to write for an auto-incrementing counter. More boilerplate and ceremony means a higher likelihood for errors and worse developer experience.
When hooks showed up, I was very excited. My counter could be reduced to the following:
function Counter()
const [count, setCount] = useState(0);
useEffect(() =>
setInterval(() =>
setCount(count + 1);
, 1000);
, []);
return div>The count is: countdiv>;
Wait, that’s not actually right. Our useEffect
hook has a stale closure around count
because we haven’t included count
in the useEffect
dependency array. Omitting variables from dependency arrays are such a common mistake with React hooks that there are linting rules that will yell at you if you forget one.
I’ll get back to that in a moment. For now, we’ll add our missing count
variable into the dependency array:
Featured Content Ads
add advertising herefunction Counter()
const [count, setCount] = useState(0);
useEffect(() =>
setInterval(() =>
setCount(count + 1);
, 1000);
, [count]);
return div>The count is: countdiv>;
But now we have another problem, as we take a look at running app:
Those of you more fluent in React likely know what’s going on because you battle this kind of thing every day: we’re creating too many intervals (a new one each time the effect is re-run, which is every time we increment count
). We can solve this problem a few different ways:
- Return a cleanup function from
useEffect
hook that clears the interval - Use
setTimeout
instead ofsetInterval
(good practice still to use a cleanup function) - Use the function form of
setCount
to prevent needing a direct reference to the current value
Indeed any of these will work. We’ll implement the last option here:
Featured Content Ads
add advertising herefunction Counter()
const [count, setCount] = useState(0);
useEffect(() =>
setInterval(() =>
setCount((count) => count + 1);
, 1000);
, []);
return div>The count is: countdiv>;
And our counter is fixed! Since nothing’s in the dependency array, we only create one interval. Since we use a callback function for our count setter, we never have a stale closure over the count
variable.
This is a pretty contrived example, yet it’s still confusing unless you’ve been working with React for a bit. More complex examples, which many of us encounter on a day-to-day basis, confuse even the most seasoned React developers.
I’ve thought a lot about hooks and why they don’t feel quite right. I found the answer, it turns out, by exploring Solid.js.
The problem with React hooks is that React isn’t truly reactive. If a linter knows when an effect (or callback, or memo) hook is missing a dependency, then why can’t the framework automatically detect dependencies and react to those changes?
The first thing to note about Solid is that it doesn’t try to reinvent the wheel: it looks a lot like React from afar because React has some tremendous patterns: unidirectional, top-down state; JSX; component-driven architecture.
If we started to rewrite our Counter
component in Solid, we would start out like this:
function Counter()
const [count, setCount] = createSignal(0);
return div>The count is: count()div>;
We see one big difference so far: count
is a function. This is called an accessor and it’s a big part of how Solid works. Of course, we have nothing here about incrementing count
on an interval, so let’s add that.
function Counter()
const [count, setCount] = createSignal(0);
setInterval(() =>
setCount(count() + 1);
, 1000);
return div>The count is: count()div>;
Surely this won’t work, right? Wouldn’t new intervals be set each time the component renders?
Nope. It just works.
But why? Well, it turns out that Solid doesn’t need to rerun the Counter
function to re-render the new count
. In fact, it doesn’t need to rerun the Counter
function at all. If we add a console.log
statement inside the Counter
function, we see that it runs only once.
function Counter()
const [count, setCount] = createSignal(0);
setInterval(() =>
setCount(count() + 1);
, 1000);
console.log('The Counter function was called!');
return div>The count is: count()div>;
In our console, just one lonely log statement:
"The Counter function was called!"
In Solid, code doesn’t run more than once unless we explicitly ask it to.
It turns out I solved our React useEffect
hook without having to actually write something that looks like a hook in Solid. Whoops! But that’s okay, we can extend our counter example to explore Solid effects.
What if we want to console.log
the count
every time it increments? Your first instinct might be to just console.log
in the body of our function:
function Counter()
const [count, setCount] = createSignal(0);
setInterval(() =>
setCount(count() + 1);
, 1000);
console.log(`The count is $count()`);
return div>The count is: count()div>;
That doesn’t work though. Remember, the Counter
function only runs once! But we can get the effect we’re going for by using Solid’s createEffect
function:
function Counter()
const [count, setCount] = createSignal(0);
setInterval(() =>
setCount(count() + 1);
, 1000);
createEffect(() =>
console.log(`The count is $count()`);
);
return div>The count is: count()div>;
This works! And we didn’t even have to tell Solid that the effect was dependent on the count
variable. This is true reactivity. If there was a second accessor called inside the createEffect
function, it would also cause the effect to run.
Reactivity, not lifecycle hooks
If you’ve been in React-land for a while, the following code change might be really eyebrow-raising:
const [count, setCount] = createSignal(0);
setInterval(() =>
setCount(count() + 1);
, 1000);
createEffect(() =>
console.log(`The count is $count()`);
);
function Counter()
return div>The count is: count()div>;
And the code still works. Our count
signal doesn’t need to live in a component function, nor do the effects that rely on it. Everything is just a part of the reactive system and “lifecycle hooks” really don’t play much of a role.
Fine-grained DOM updates
So far I’ve been focusing a lot on developer experience (e.g., more easily writing non-buggy code), but Solid is also getting a lot of praise for its performance. One key to its strong performance is that it interacts directly with the DOM (no virtual DOM) and it performs “fine-grained” DOM updates.
Consider the following adjustment to our counter example:
function Counter()
const [count, setCount] = createSignal(0);
setInterval(() =>
setCount(count() + 1);
, 1000);
return (
div>
The (console.log('DOM update A'), false) count is: ' '
(console.log('DOM update B'), count())
div>
);
If we run this, we get the following logs in our console:
DOM update A
DOM update B
DOM update B
DOM update B
DOM update B
DOM update B
DOM update B
In other words, the only thing that gets updated every second is the small piece of the DOM that contains the count
. Not even the console.log
earlier in the same div
is re-run.
I have enjoyed working with React for the past few years; it always felt like the right level of abstraction from working with the actual DOM. That being said, I have also become wary of how error-prone React hooks code often becomes. Solid.js feels like it uses a lot of the ergonomic parts of React while minimizing confusion and errors. I tried to show you some of the parts of Solid that gave me “aha!” moments, but I recommend you check out https://www.solidjs.com and explore the framework for yourself.
If you’d like to support this blog by buying