Signals are VALUES, not EVENTS
Tomasz Ducin
Posted on January 18, 2024
Recent release of Angular 17.1 (yesterday, 18th January 2024), introduces Signal Inputs (read more about Angular Signal Input API here):
It's a good opportunity to rethink migrating from RxJS to Signals, as more and more APIs are signal-based: the framework (signals, computeds, effects and the rxjs-interop) and ecosystem (NGRX signal store), as well as tons of experiments (such as this one).
Photo by Alina Grubnyak on Unsplash
The amount of positive reception of above tweet made me realize this topic needs more attention among the Angular community.
On one hand, we've all heard that signals won't entirely replace RxJS (especially that Angular is on the road to make RxJS optional in the future). On the other hand, the new reactive primitive is taking the ecosystem by storm and, no wonder, it's easy to lose touch with the design principles and start overusing signals everywhere. Just like redux used to be abused at some point. Or like Observable Streams used to be abused. Are we gonna share Georg Hegel's view, that:
"We learn from history that we don't learn from history."
okay no more my tweets 😇
Micro disclaimer
I enjoy using signals, I enjoy using rxjs observables - so I'm no pro/against in any way. Also I enjoy using MobX (doesn't MobX API look familiar to you? 🙃), I enjoy bacon... I enjoy pretty much all the tools that help me get my job done. My intention in this post to emphasize, again, that every tool has it's certain usecases. And that excitement is the worst factor that could drive decision making in software development. Cool down 🥶
Facts
Let's start with revisiting the fundamental characteristics of signals and observable streams (they're mostly well known, so I'll just list them down):
- sync/async
- observables can do both sync and async processing
- current signals implementation is capable of sync processing only
- as a consequence, signals are unaware of the passage of time
-
some APIs will always be async no matter what we prefer or believe in
¯\_(ツ)_/¯
, such as HTTP or browser events- replacing decorated
@Output()
with signal-basedoutput()
(not yet in v17.1) will be purely syntactic sugar, as events will be emitted underneath nevertheless - squashing async processing into sync signals generates some non-trivial questions
- replacing decorated
- subscriptions
- RxJS requires manual (un)subscription handling
- signals handle (un)subscription 100% automatically
- late subscriptions
- both APIs handle late subscriptions
- values/emissions
- signal always has a value (like in
BehaviorSubject
) - observable streams might have values, but streams with no emissions whatsoever are absolutely valid(
------->
- nonext
, noerror
, nocomplete
, nothing)
- signal always has a value (like in
- re-implementing RxJS features as signals one by one (like operators, subjects etc.) makes barely any sense
- API
- RxJS has ~100 operators, 4 subject types, 32 types of hot observables (expect a blogpost soon! 🔥), all unicasting/multicasting concepts, etc.
- signals have barely ~10 functions (signal, set, update, computed, effect, untracked, asReadonly, toSignal, toObservable, perhaps something more). Order of magnitude difference...
Readability
I'll be honest - when people say they find something readable or not... or easy... or simple... I tell you: I don't care much 😉 people often forget that we have different background and experience. That readability has no way to be measured, and is purely 100% subjective. Like, my mother tongue is Polish and, surprise surprise, I can understand quite a lot from other Slavic languages. So what? So nothing. It would be strange if I didn't 🤷.
And same here. I love signals from the first day. But that's mainly because I have commercial experience in MobX which is, conceptually, 1:1. For every person, a thing being readable or not is ultimately subjective and - my tiny recommendation here: don't pay too much attention to readability. Unless you like bikeshedding.
Missing piece of understanding
Alex has once described signals the following way:
A signal is a source of value.
That makes perfect sense. However, if I asked you the following question:
What a signal is NOT? What shall NOT be represented as a signal?
What would be your answer?
Coding itself is rather simple, but software design is not. If we pick a wrong abstraction and keep on implementing things on top of it, then despite that our code might work as expected, we'd see things are unnecessarily harder than they should be. Within time we'd start fighting our code and design, more and more, and feel the need to refactor/rewrite some piece of an app.
So what a signal is NOT? I suggest one of the following:
- something that doesn't have a current value, e.g. user events, browser events
- something that has a current value, but it's completely irrelevant, an action that a user wants to reload a data-table
So I want to draw a boundary between VALUES and EVENTS.
All in all, I would rephrase the definition to:
A signal is a source of value, but NOT a source of events.
Values are some DETAILS, characteristics that a thing can have
We can always ask (pull-based) what the value is. We can also be notified about a value which has changed (push-based). That's why RxJS is a valid abstraction around pretty much everything that exists 😉. But still, if something has a value, you can always ask for it.
Events describe what has already HAPPENED or what will happen
The events, on the other hand, are meant to notify (push-based) others about something that happened. There is an important distinction between events (something has happened already) and commands (something will happen and we pass a command to be executed) but it's a topic for another post.
The distinction between VALUES and EVENTS is very clearly expressed in the original redux
store API:
store.getState(): STATE // pull-based value accessor
store.subscribe(FN): void // push-based value accessor
store.dispatch(ACTION): void // push-based event notification
Couldn't events be values or vice versa?
Someone might deny:
Hey, but events are objects, they might have payloads, and what is a payload if not a value?! (object, primitive, whatever)
Sure. Technically - yes, absolutely. But, taken to extreme, everything is a value. Even your mouse cursor's coordinate is a value. But when we do design, we want to create mental models that are pragmatic, help us put things in order, and make other devs comfortable within the design that we leave. Taking things to extreme doesn't help.
It all depends on the terminology we use and the context of our reasoning, so - could events be values - both yes and both no.
My strong recommendation is to focus on what is the intention of how are we going to use a thing:
- are we going to access details of something (value)? or
- do we get notified that something has happened (or will happen)?
Community-driven signals APIs
There would certainly be more and more signal-based APIs. It's worthwhile to consider which of the make sense and which don't. Mike sums it up perfectly:
Example: interval
I want to focus on just ONE example, but do it very deep. In the react world (where I come from 😜) one of the mindshifting blogposts is Dan Abramov's Making setInterval Declarative with React Hooks. I decided to pick setInterval
as well.
So, could you make an async interval signal? Technically, yes, of course:
function intervalSignal(interval){
const idx = signal(0)
const id = setInterval(() => {
idx.update(value => value + 1)
}, interval)
return idx
}
But should you? No. Totally No.
Why? Because, although technically we could easily make things work, we're mixing abstractions badly. Mixing abstractions will always have some shortcomings. What are these?
Missing support
We're also missing support for potentially important aspects. I hope many of you already thought: why aren't you cleaning up the signal?! Great, so here we go:
function intervalSignal(interval){
const idx = signal(0)
const id = setInterval(() => {
idx.update(value => value + 1)
}, interval)
return {
cleanup(){ clearInterval(id) },
signal: idx
}
}
but hey, our implementation more complex and, as a consequence, longer.
Fixes. Fixes everywhere.
Have you noticed, that if we expose a WritableSignal
(signal still includes the .set
) method, then we're leaking encapsulation - probably overwriting the current tick shouldn't be possible from outside of the signal. So, instead, it should be:
...
return {
cleanup(){ clearInterval(id) },
signal: idx.asReadonly()
}
But, trust me, we haven't exhausted all edge cases yet 😈
Signal Interval is pretending to be time-aware
In the last version of the signal (interval + cleanup + asReadonly) there's still no simple API that allows us to modify the interval (time) length. In RxJS we could simply use a switchMap
which consumes the interval upstream:
intervalLength$.pipe(
switchMap(timeLength => interval(timeLength))
)
Again, could we achieve the similar API with signals? Of course! (but I'm not falling down the rabbit hole 😏). Anyway, as the signal code gets more complex:
- we should take potential edge cases into account, right? And most probably we're bikeshedding and it's far away from the business solutions we're expected to work on...
API design
We also need to carefully design APIs (again). Potential breaking changes come into play. Also, how much of your application code should re responsible for platform-related things, like intervals, events, HTTP, etc?
In case of changing the interval length, should we go one more setter callback?
function intervalSignal(interval): {
signal: Signal<number>
cleanup: () => void,
setIntervalTime: (time: number) => void
}
This seems simple, but the signal API has become rather callback-based. Perhaps wrapping interval with a signal would look better to use signals more:
function intervalSignal(interval): {
valueSignal: Signal<number>
cleanup: () => void,
intervalTimeSignal: Signal<number>
}
which one is better?
Wrong Abstractions, AGAIN
There's one important thing we will consider 😄 there might be situations, where we need to process values of the signals and a signal
/computed
fits here, since it can notify computeds further. But other times, it's fairly enough to make it an effect
, as the consumption would be placed within the effect function and there would be no further dependencies (nobody would have to react to the effect taking place. Potentially that could have been a better choice from the beginning...
However, we can't guarantee that effect
s would be better than signal
s in all cases.
Conclusion
Can you see, how many problems we're solving, which don't relate to our commercial project? 😐
I hope we all clearly see that: if the problem-solution abstractions don't fit well, better not to implement the solution at all.
What's the conclusion? If a concept is beyond signals (doesn't fit there), like time, or events - don't make signals wrap it.
So, to sum it all up - what is the most important factor that differentiates VALUES from EVENTS? It's TIME. VALUES don't care about time. EVENTS do.
Pay attention to time. As time is running out. ☠️
Follow me on twitter for more frontend (js, ts, react, angular etc.) deep dives!
Posted on January 18, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.