There has been a lot of talk about signals inside frameworks. But a natural question to ask is how signals are different from observables. Well, that is a good question and the goal of this article, so read on!
We all know what value is!
const answer = 42;
So what is a signal?
const answer = useSignal(42);
OK, what about an observable?
const answer = from([42]);
It seems similar, but let’s try to console.log()
the value out.
console.log('Answer', answer);
Using signals:
console.log('Answer', answer.value);
And finally, using observables:
answer.subscribe((value) => console.log('Answer', value));
I think observables stand out because they are the only case in which we could not just get the value; instead, we were required to create a callback and a subscription. This difference is at the heart of the signal versus observable difference.
Let’s say we have a value represented by a shaded circle in the above diagram. We all know how to work with values. It is just a reference to the value. We can pass the value (or reference) around to different parts of our application.
And any part of the application can read the value to use it. But values are not reactive! There is no way for an “observer” to “know” when the value has changed. And so, while working with values is straightforward, they lack reactivity!
A signal is like a bucket that contains the value. You can pass the bucket around in the same way you pass the value to the rest of your application. The application can at any time look into the bucket (read) or update what is in the bucket (write).
But because it is a bucket, the bucket “knows” when the read or write has happened. So now an “observer” can “know” when a read or write has occurred. Being able to “know” when there is a read/write is what makes signals reactive!
An observable is quite different from a value or a signal. An observable is like a pipe. At any time, the pipe can deliver a new value. This has several implications. First, you can’t just look into a pipe whenever it suits you. Instead, you have to register a callback and wait for the callback to be invoked when the new value shows up. Second, observables deliver values over time. This “time” concept is core to observables. (Whereas signals have no concept of time, it is just whatever is in the bucket.)
This is why in the above example, we could do a direct read of the value or signal but needed to create a callback for observables. This difference, a bucket for value versus a pipe of things to be delivered over time, is the core difference between signals and observables, and it has a lot of implications. So let’s explore the consequences of this difference.
We kind of already touched on this, but let’s review it just to make it more clear.
Values can just be passed around in your application. Nothing special here.
Both in the case of signals and observables, we don’t pass the value but a container containing the value of interest. This is an important distinction because passing a container allows you to pass it to the rest of the application before you have the value which needs to be passed. In essence, passing the container allows you to set up the “plumbing” of the system and later pass the value through the plumbing.
Observables are not a container of value but rather a container of values over time. The time component is very important to the concept of observables.
Because observables are values over time, you can’t just “read” the current value. There is no “current” value. That makes no sense in the observable world. Instead, you have to register a callback, and the observable will invoke the callback when a value shows up. This is why a pipe analogy is closer to what observable is.
Accessing the value of a reference is basic. It is what vanilla JavaScript is.
Accessing a value of a signal is also pretty straightforward, but it requires some form of getter execution. Fundamentally, there are really only two choices: invoking a function or accessing a property (which indirectly invokes the getter function)
// retrieving function style:
const signalValue = signal();
// retrieving property getter style:
const signalValue = signal.value;
NOTE: discussing the tradeoffs between the two styles will make for a good future article, but it is beyond the scope of this one.
Accessing a value of an observable is a bit more complicated. One can’t just look into an observable because, as we mentioned previously, an observable is values over time. So observables require that we register a callback function.
The callback function has interesting implications. It means that our callback function will not get called immediately with the last value (that has already passed). Instead, nothing happens until a new value shows up. Contrast this with signals, where you can get a current value.
The implication of getters vs. callbacks is that signals are pull-based (the code can read signal value synchronously) whereas observables are push-based (observables deliver the value to your callback when a value shows up.)
One subscribes to observables by invoking the subscribe method and passing in a callback. So we call this an “explicit” subscription. (Yes, a framework can sometimes call the subscription on your behalf, but there is still a subscription.)
While signals can also have “explicit” subscriptions, most rely on “implicit” subscriptions. Let’s cover how that works.
const firstName = useSignal('');
const lastName = useSignal('');
const fullName = useComputed$(() => {
return lastName.value + ', ' + firstName.value;
});
In the above example, the useComputed$()
closure executes inside a special context. This context “observes” signal reads and records them. This in essence creates a subscription “implicitly” without the need to listen to each signal individually. There is no “explicit subscribe” API. (Many signal implementations don't have “subscribe” API.)
Signals are a synchronous execution model. One synchronously reads/writes values from/to the signals. If you want to process async code, most signal implementations have some “effect” APIs that allow you to do so.
The synchronous nature of signals is implicit in that they create “implicit” subscriptions. If the “computed” callback were async, it would not be possible for the context to observe the reads, as it would have no way of knowing how much into the future it should be observing reads/writes.
Observables are very much async in nature. After all, they are “values over time.” Time implies asynchronicity. But observable pipe implementations are often times a combination of sync and async callbacks.
For this reason, it is sort of a hybrid model. Pushing a new value into observable may or may not call the subscription synchronously.
In observables, one usually sets up the reactivity graph during a “setup phase,” and then the graph tends to remain static (there are APIs for changing it, but that is not how most people use it).
Signals tend to have very dynamic graphs. Take this code as an example:
const location = useSignal('37°46′39″N 122°24′59″W');
const zipCode = useSignal('94103');
const preference = useSignal('location');
const weather = useComputed$(() => {
switch (preference.value) {
case 'location':
return lookUpWetherByGeo(location.value);
case 'zip':
return lookUpWetherByZip(zipCode.value);
default:
return null;
}
});
In the above example, the weather
signal can subscribe to zipCode
, location
or neither, depending on the value of the preference
. It is just how signals work.
Are signals better than observables? That is a wrong question. They are two different things, and choosing observables over signals depends on what you are trying to achieve.
If values over time are the key concept for your problem domain, then observables are the right choice. On the other hand, if the “time” component of signals is not something you need for your problem domain, then using observables brings unnecessary complexity.
The way I think about it is that it is a spectrum. On one extreme, you have values. They are simple but lack the expressivity, which is, at times, useful.
On the other extreme are observables, which can do anything but come with more complex APIs. In the middle are signals, which are not as powerful as observables but are way more straightforward.
So values, signals, and observables are a tradeoff of complexity and expressivity. You chose what is right for your problem domain.
If your problem domain is building UIs, I think observables are overkill most of the time. Yes, there are a few areas where observables shine, but for most UIs, signals are good enough and the fact that signals have a smaller API surface is the right trade-off in most cases, which is why signals are the future of web frameworks.
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.