Sometimes RxJS is the perfect tool for the job. If you've used it for the wrong job, you might never want to touch it again. But if you remain open-minded towards it, it can sometimes save your codebase from exploding into overly complex spaghetti-code.
We've seen what happens when we use RxJS for synchronous reactivity. Now let's try to use signals to implement asynchronous behavior. We'll compare both RxJS and signal implementations of this dinosaur animation from the opening screen of the Angular Tetris app (See the demo here, but be careful... you might lose an hour):
Let's look at the implementations first, then analyze them.
Implementations
RxJS
export class LogoComponent {
runningClass$ = timer(0, 100).pipe(
startWith(0),
takeWhile((t) => t < 40),
map((t) => {
const range = Math.ceil((t + 1) / 10);
const side = range % 2 === 0 ? 'l' : 'r';
const runningLegState = t % 2 === 1 ? 3 : 4;
const legState = t === 39 ? 1 : runningLegState;
return `${side}${legState}`;
})
);
blinkingEyesClass$ = timer(0, 500).pipe(
startWith(0),
takeWhile((t) => t < 5),
map((t) => `l${t % 2 === 1 ? 1 : 2}`)
);
restingClass$ = timer(5000).pipe(
startWith(0),
map(() => 'l2')
);
dragonClass$ = concat(
this.runningClass$,
this.blinkingEyesClass$,
this.restingClass$
).pipe(repeat(Infinity));
}
Signals
type State = 'running' | 'blinking' | 'resting';
type RunningSide = 'r' | 'l';
interface RunningState {
count: number;
side: RunningSide;
}
// ...
export class LogoComponent {
private runState = signal<RunningState>({ count: 1, side: 'r' });
private blinkingCount = signal(1);
private state = signal<State>('running');
className = computed(() => {
switch (this.state()) {
case 'running': {
return this.getRunningClassName();
}
default: {
return this.getBlinkingClassName();
}
}
});
private intervalRef = null as null | number;
private setTimeoutRef = null as null | number;
constructor() {
effect(
() => {
switch (this.state()) {
case 'running': {
if (!this.intervalRef) {
this.intervalRef = this.startRunning();
}
const { count } = this.runState();
if (count > 40) {
this.clearInterval();
this.state.set('blinking');
this.runState.set({ count: 1, side: 'r' });
}
break;
}
case 'blinking': {
if (!this.intervalRef) {
this.intervalRef = this.startBlinking();
}
if (this.blinkingCount() >= 6) {
this.clearInterval();
this.state.set('resting');
this.blinkingCount.set(1);
}
break;
}
case 'resting': {
this.setTimeoutRef = window.setTimeout(() => {
this.state.set('running');
}, 5000);
break;
}
}
},
{ allowSignalWrites: true }
);
inject(DestroyRef).onDestroy(() => {
if (this.intervalRef) {
clearInterval(this.intervalRef);
}
if (this.setTimeoutRef) {
clearTimeout(this.setTimeoutRef);
}
});
}
private startBlinking() {
return window.setInterval(() => {
this.blinkingCount.update((count) => count + 1);
}, 500);
}
private startRunning() {
return window.setInterval(() => {
this.runState.update(({ count, side }) => {
const newCount = count + 1;
return {
count: newCount,
side:
newCount === 10 || newCount === 20 || newCount === 30
? side === 'r'
? 'l'
: 'r'
: side
};
});
}, 100);
}
private getRunningClassName(): string {
const { count, side } = this.runState();
const state = count === 41 ? 1 : count % 2 === 0 ? 3 : 4;
return `${side}${state}`;
}
private getBlinkingClassName() {
const state = this.blinkingCount() % 2 === 0 ? 1 : 2;
return `l${state}`;
}
private clearInterval() {
if (this.intervalRef) {
clearInterval(this.intervalRef);
}
this.intervalRef = null;
}
}
Lines of Code
RxJS wins at this:
Lines of Code | |
---|---|
RxJS | 30 |
Signals | 123 |
More code usually takes more time to understand, leaves more room for mistakes and is harder to change.
Level of Abstraction
The signals implementation is working on a lower level of abstraction. This RxJS code:
takeWhile((t) => t < 40),
Becomes this with signals:
if (count > 40) {
this.clearInterval();
this.state.set('blinking');
this.runState.set({ count: 1, side: 'r' });
}
RxJS operators describe actual behavior. If you don't use them explicitly (or something equivalent), you will be redefining them implicitly in scattered, repetitive logic coupled to business logic.
A developer who understands RxJS operators automatically understands more potential behavior of features, so the cost of becoming familiar with a new codebase that uses RxJS is less than the cost of learning the equivalent codebase that doesn't use them.
Learning RxJS is a good investment for your career.
Debugging
When something is wrong it is usually faster to directly inspect the thing that's wrong instead of making a guess about what code is related and trying to find the problem from there.
Imagine you wanted to know why the dragon was blinking for 2.5 seconds. If you directly inspected the dragon, you would find this (in the RxJS implementation):
<div [ngClass]="['bg dragon', dragonClass$ | async]"></div>
If you Cmd Click
on dragonClass$
, you see this:
dragonClass$ = concat(
this.runningClass$,
this.blinkingEyesClass$,
this.restingClass$
).pipe(repeat(Infinity));
concat
means "run these back-to-back". repeat(Infinity)
repeats the previous behavior Infinity
times.
So now we already understand the main behavior, and we could Cmd Click on blinkingEyesClass$
to go into more detail.
This is a very convenient debugging experience, and it's because RxJS enables declarative syntax for even asynchronous behavior.
Declarative Code
Most of the benefits of the RxJS approach come from its declarative structure.
What does that mean?
Declarative Code Definition
Declarative code is where each declaration completely defines a value across time. The answer to the question "Why is this thing behaving this way?" is always answered in its declaration, not somewhere else.
Non-declarative code only initializes values, then leaves it up to scattered imperative code to define subsequent behavior. To answer the question "Why is this behaving this way?", you have to find all references to the variable, understand the context of each of those references, then determine if and how those references are controlling that variable.
Examples of Declarative vs Non-Declarative Code
UI
Declarative
<div>{{count()}}</div>
Non-Declarative
<div id="count">0</div>
countElement.innerText = +countElement.innerText + 1;
Variables
Declarative
const count = 0;
Non-Declarative
let count = 0;
// ...
count++;
State
Declarative
const count$ = increment$.pipe(scan(n => n + 1, 0));
const count = toSignal(count$);
Non-Declarative
const count = signal(0);
// ...
count.set(count() + 1);
Effect Events
Declarative
const increment$ = incrementRequest$.pipe(
mergeMap(() => server.increment()),
);
Non-Declarative
const increment = createAction('INCREMENT');
// ...
increment$ = this.actions$.pipe(
ofType('INCREMENT_REQUEST'),
mergeMage(() => server.increment()),
map(() => increment()),
);
(Note: Technically it's the store itself that's non-declarative, being modified by store.dispatch
. But effectively each action is an event stream, and this code is defining when it gets dispatched.)
UI Events
Declarative
const incrementRequest$ = fromEvent(incrementButton, 'click');
Non-Declarative
const incrementRequest$ = Subject<void>();
// ...
incrementRequest$.next();
Benefits of declarative code
Focus
Declarative code allows behavior to be self-contained and isolated. When you're working on one specific feature or behavior, you don't have to worry about imperative statements elsewhere changing it, so you can ignore the entire rest of the world and just define that one thing.
Debugging
When behavior is being controlled from one place, rather than from many places, bugs are easier to track down.
Avoiding Bugs
It's easier to avoid bugs when you can reference all relevant context when editing code that defines its behavior. This is the reason Facebook invented Flux for state management.
Comprehension
It's easier to quickly understand code when it's organized by what it does rather than by when it's executed. Callback functions contain imperative code that runs at the same time (mostly) but controls unrelated states, whereas reactive declarations organize code by the actual behavior they define.
Declarativeness in RxJS vs Signals Only
I color-coded the RxJS and signals-only implementations by what state/behavior the code is defining/modifying:
The signals implementation is not very declarative. This is because signals and computeds enable declarative code only for synchronous behavior. When time enters the equation, you need an effect
, which, like all callback functions, is a container for imperative code. Since this feature involves a lot of timing behavior, it requires a lot of scattered imperative logic inside an effect
and a few other places.
RxJS operators are currently the only comprehensive way to enable declarative code for asynchronous behavior. The only way to declare an asynchronous relationship between 2 variables is to define how they are related, which involves the logic contained in the operators.
For example, delay
and debounceTime
are just 2 different ways in which a reactive asynchronous relationship can be defined:
const b$ = a$.pipe(delay(20));
const b$ = a$.pipe(debounceTime(10));
Signal Operators
Could there be a way to define reactive asynchronous relationships without RxJS operators? RxJS operators are just functions that take in observables and return different observables. Could we write functions that could take in signals and return different signals and call them "signal operators"?
I think we can.
Let's try delay
:
export function delay<T>(inputSignal: Signal<T>, t: number) {
const outputSignal = signal(undefined as undefined | T);
effect(() => {
const value = inputSignal();
setTimeout(() => outputSignal.set(value), t);
}, { allowSignalWrites: true });
return outputSignal;
}
This can be used like this:
count = signal(0);
delayedCount = delay(this.count, 3000);
This works, and delayedCount
is 100% declarative syntax! We abstracted away the effect
usage and imperative code, and enabled a declarative API.
This is an extremely simple example. Something much more sophisticated is required to achieve what the dozens of RxJS operators achieve.
So, my advice is this:
When you think you might need to write an effect for a signal, first try instead to
- reorganize the code to use a
computed
- use an existing signal operator
- write a new custom signal operator
- convert to an observable with
toObservable
and use an RxJS operator
And only as a last resort, use effect
directly.
Currently you will have to always either use RxJS operators, or write your own signal operators. But I think I am going to work on a library for signal operators. I explained the full reasons in this tweet thread and in this YouTube video. Follow me on Twitter for updates :)
Conclusion
This example was almost completely asynchronous. What you will see in the real world will land somewhere on the spectrum between synchronous and asynchronous.
The more asynchronous your app is, the more RxJS can help you keep your codebase clean, bug-free and easy to debug.
Just remember:
Avoid effect
.
- Use
computed
. - Use a signal operator.
- Create a signal operator.
- Use RxJS.
Thanks for reading! Follow me on Twitter for more frequent content: https://twitter.com/mfpears