A signal is a way to store the state of your application, similar to useState()
in React. But there are some key differences that give Signals the edge.
The key difference between Signals and State is that Signals return a getter and a setter, whereas non-reactive systems return a value (and a setter). Note: some reactive systems return a getter/setter together, and some as two separate references, but the idea is the same.
The issue is that the word State conflates two separate concepts.
- StateReference: The state-reference is a reference to the state.
- StateValue: This is the actual value stored in state reference/storage.
Why is returning a getter better than returning a value? Because by returning the getter, you can separate the passing of the state-reference from a reading of the state-value.
Let’s look at this SolidJS code as an example.
createSignal()
: allocates theStateStorage
and initializes it to0
.getCount
: a reference to the store that you can pass around.getCount()
: says retrieve the state value.
The above explains how Signals are different from the good old state but does not explain why we should care.
Signals are reactive! This means that they need to keep track of who is interested in the state (subscriptions) and, if the state changes, notify the subscribers of the state change.
To be reactive, Signals must collect who is interested in the Signal’s value. They gain this information by observing in what context the state-getter is invoked. By retrieving the value from the getter, you are telling the signal that this location is interested in the value. If the value changes, this location needs to be re-evaluated. In other words, invoking the getter creates a subscription.
This is why passing the state-getter rather than the state-value is important. The passing of state-value does not give the signal any information about where the value is actually used. This is why distinguishing between state-reference and state-value is so important in signals.
For comparison, here is the same example in Qwik. Notice that (getter/setter) has been replaced with a single object with a .value
property (which represents the getter/setter). While the syntax is different, the inner workings remain the same.
Importantly, when the button is clicked and the value is incremented, the framework only needs to update the text node from 0
to 1
. It can do that because, during the initial rendering of the template, the Signal has learned the count.value
has been accessed by the text node only. Therefore it knows that if the value of the count
changes, it only needs to update the text node and nothing else.
Let’s look at how React uses useState()
and its shortcomings.
React useState()
returns a state-value. This means that useState()
has no idea how the state-value is used inside the component or the application. The implication is that once you notify React of state change through a call to setCount()
, React has no idea which part of the page has changed and therefore must re-render the whole component. This is computationally expensive.
React has useRef()
, which is similar to useSignal()
, but it does not cause the UI to re-render. This example looks very similar to useSignal()
but it will not work.
useRef()
is used exactly like a useSignal()
to pass a reference to the state rather than the state itself. What useRef()
lacks are subscription tracking and notifications.
The nice thing is that in signal-based frameworks, useSignal()
and useRef()
are the same thing. useSignal()
can do what useRef()
does plus subscription tracking. This further simplifies the API surface of the framework.
Signals rarely require memoization because they do the least amount of work out of the box.
Consider this example of two counters and two children components.
In the above example, only the text node of one of the two Display
components will be updated. The text node that doesn't get updated will never print after the initial render.
# Initial render output
<Counter/>
<Display count={0}/>
<Display count={0}/>
# Subsequent render on click
(blank)
You actually can’t achieve the same in React because, at the very least, at least one component needs to re-render. So let’s look at how to memoize components in React to minimize the amount of re-rendering.
But even with memoization, React will rerun the re-render much more.
# Initial render output
<Counter/>
<Display count={0}/>
<Display count={0}/>
# Subsequent render on click
<Counter/>
<Display count={1}/>
Without memoization, we would see:
# Initial render output
<Counter/>
<Display count={0}/>
<Display count={0}/>
# Subsequent render on click
<Counter/>
<Display count={1}/>
<Display count={0}/>
That is a lot more work than what Signals have to do. So, this is why signals work as if you memoized everything without actually having to memoize anything yourself.
Let’s take a common example of implementing a shopping cart.
The state of the cart is usually pulled up to the highest common parent between the buy button and where the cart is rendered. Because the buy button and the cart are far apart in the DOM, this often is very close to the top of the component render tree. In our case, we call it the common ancestor component.
The common ancestor component has two branches:
- One which drills the
setCart
functions through many layers of components until it reaches the buy button. - The other drills the
cart
state through many layers of components until it reaches the component which renders the cart.
The problem is that every time you click the buy button, most of the component tree has to rerender. This leads to an output similar to this:
# "buy" button clicked
<App/>
<Main/>
<Product/>
<NavBar/>
<Cart/>
If you do use memoization then you can avoid the setCart
prop-drilling branch but not the cart
prop-drilling branch, so the output would still look like so:
# "buy" button clicked
<App/>
<NavBar/>
<Cart/>
With signals, the output is like so:
# "buy" button clicked
<Cart/>
This greatly reduces the amount of code that needs to execute.
Some of the more popular frameworks which support signals are Vue, Preact, Solid, and Qwik.
Now, signals are not new; they have existed in Knockout and probably other frameworks before then. What is different is that signals have greatly improved their DX in recent years through clever compiler tricks and deep integration with JSX, which makes them very succinct and a pleasure to use — and that part is genuinely new.
A signal is a way to store state in an application, similar to useState()
in React. However, the key difference is that signals return a getter and a setter, whereas non-reactive systems return only a value and a setter.
It is important because signals are reactive, meaning they need to keep track of who is interested in the state and notify subscribers of state changes. This is achieved by observing the context in which the state-getter is invoked, which creates a subscription.
In contrast, useState()
in React returns only the state-value, meaning it has no idea how the state-value is used and must re-render the whole component tree in response to state changes.
In recent years signals have reached a DX which makes them no harder to use than traditional systems. For this reason, I think the next framework you will use will be reactive and based on signals.
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.