Published 10-26-2023
// 100% Powered by Signals ✅
counter = signal(DEFAULT_COUNTER)
ticking = computed(() => this.counter().ticking)
speed = computed(() => this.counter().speed)
diff = computed(() => this.counter().diff)
up = computed(() => this.counter().up)
adhocCount = computed(() => this.counter().adhocCount)
// Simple state management ✅
start = () => this.counter.update(v => ({...v, ticking: true}))
stop = () => this.counter.update(v => ({...v, ticking: false}))
countUp = () => this.counter.update(v => ({...v, up: true}))
countDown = () => this.counter.update(v => ({...v, up: false}))
incrementBy = (diff: number) => this.counter.update(v => ({...v, diff}))
setSpeed = (speed: number) => this.counter.update(v => ({...v, speed}))
setCount = (count: number) => this.counter.update(v => ({...v, count, adhocCount: count}))
setAdhocCount = (count: number) => this.counter.update(v => ({...v, adhocCount: count}))
reset = () => this.counter.set(DEFAULT_COUNTER)
// Easy event handling ✅
ticker = effect(() => {
const ticking = this.ticking();
const speed = this.speed();
const diff = this.diff();
const up = this.up();
untracked(() => this.tick(ticking, speed, diff, up))
})
// Native Web APIs ✅
interval: NodeJS.Timeout | undefined = undefined
tick(ticking: boolean, speed: number, diff: number, up: boolean) {
clearInterval(this.interval)
if (ticking) {
const increment = up ? diff : diff * -1
this.interval = setInterval(() =>
this.counter.update(v => ({...v, count: v.count + increment})),
speed)
}
}
This example is based on Michael Hladky’s fantastic presentation RxJS Operating Heavily Dynamic UIs. I followed this workshop live at ng-conf years ago. It’s very well written rxjs and a great example to convert to signals. So, why rewrite in Signals?
If you’re not familiar with Angular signals see these previous articles
- 👉 Angular Signals in 3 Minutes
- 👉 Angular Signals: Effective Effects
- 👉 Angular Advanced Signals
- 👉 State Management with Nested Signals (Experimental)
For the original rxjs source code
For the complete code 🚀 Source Code on Github | 🚀 Demo
This is a simplified version of our count component. counter
is our source of truth. We’ll create multiple computed
signals from this source. start
and stop
are wrappers around signal update functions for convenience. ticker
is an effect that calls our tick
function when ticking
changes.
untracked
prevents signals in tick
from being added as dependenciesimport { computed, effect, signal, untracked } from '@angular/core';
DEFAULT_COUNTER = {
count: 0,
ticking: false,
speed: 1000,
up: true,
diff: 1,
adhocCount: 10
}
// Signals
counter = signal(this.DEFAULT_COUNTER)
ticking = computed(() => this.counter().ticking)
// Updates
start = () => this.counter.update(v => ({...v, ticking: true}))
stop = () => this.counter.update(v => ({...v, ticking: false}))
// Call Tick
ticker = effect(() => {
const ticking = this.ticking();
untracked(() => this.tick(ticking))
})
// Timer
interval: NodeJS.Timeout | undefined = undefined
tick(ticking: boolean) {
clearInterval(this.interval)
if (ticking) {
this.interval = setInterval(() =>
this.counter.update(v => ({...v, count: v.count + 1})), 1000
)
}
}
<div>{{counter().count}}</div>
<div>
<button (click)="start()">Start</button>
<button (click)="stop()">Stop</button>
</div>
Notice the small import list. As the example scales, it will stay small because
In the next sections we’ll start to compare some of our signal code to the original rxjs code.
We’ll add significantly more signals, and bind those signals to their corresponding ui elements.
// → Create more computed signals from counter
speed = computed(() => this.counter().speed);
diff = computed(() => this.counter().diff);
up = computed(() => this.counter().up);
adhocCount = computed(() => this.counter().adhocCount);
// → Add more convenience functions to update state
countUp = () => this.counter.update((v) => ({ ...v, up: true }));
countDown = () => this.counter.update((v) => ({ ...v, up: false }));
incrementBy = (diff: number) => this.counter.update((v) => ({ ...v, diff }));
setSpeed = (speed: number) => this.counter.update((v) => ({ ...v, speed }));
setCount = (count: number) =>
this.counter.update((v) => ({ ...v, count, adhocCount: count }));
setAdhocCount = (count: number) =>
this.counter.update((v) => ({ ...v, adhocCount: count }));
reset = () => this.counter.set(DEFAULT_COUNTER);
// → Add more dependencies to ticker
ticker = effect(() => {
const ticking = this.ticking();
const speed = this.speed();
const diff = this.diff();
const up = this.up();
// → Update signature in next section
untracked(() => this.tick(ticking, speed, diff, up));
});
<div>
<input
type="number"
[value]="adhocCount()"
(change)="setAdhocCount(+countEle.value)"
#countEle
/>
<button (click)="setCount(+countEle.value)">Set Count</button>
</div>
<div>
<label for="incrementBy">Increment By</label>
<input
name="incrementBy"
type="number"
[value]="diff()"
(change)="incrementBy(+diffEle.value)"
#diffEle
/>
</div>
<div>
<label for="tickspeed"
>Tick Speed <span style="color: var(--muted-color)">(ms)</span></label
>
<input
name="tickspeed"
type="number"
[value]="speed()"
(change)="setSpeed(+speedEle.value)"
#speedEle
/>
</div>
<div>
<button (click)="countUp()">Count Up</button>
<button (click)="countDown()">Count Down</button>
</div>
It’s a lot of code to read all at once, but each element uses the same pattern
computed
to get the primitive value as a signal[value]="speed()"
(change)="setSpeed(+speedEle.value)"
queryChange
is a custom function that wraps pluck
and distinctUntilChange
. This example highlights the simplificty
of computed
because it uses basic Javascript “getters” and equality to have a pluck+distinctUntilChange behavior by default.
// RxJS 🐉
const count$ = counterState$.pipe(
pluck<CountDownState, number>(ConterStateKeys.count),
);
const isTicking$ = counterState$.pipe(
queryChange<CountDownState, boolean>(ConterStateKeys.isTicking),
);
const tickSpeed$ = counterState$.pipe(
queryChange<CountDownState, number>(ConterStateKeys.tickSpeed),
);
const countDiff$ = counterState$.pipe(
queryChange<CountDownState, number>(ConterStateKeys.countDiff),
);
// Signals 📡
const speed = computed(() => this.counter().speed);
const diff = computed(() => this.counter().diff);
const up = computed(() => this.counter().up);
Now we’ll utilize our changes inside of tick
to complete the counter
// → Update signature to take in dependencies from ticker effect
tick(ticking: boolean, speed: number, diff: number, up: boolean) {
clearInterval(this.interval)
if (ticking) {
// → If count down convert diff to negative
const increment = up ? diff : diff * -1
// → Add diff, instead of 1
// → set timer to speed, instead of 1000
this.interval = setInterval(() =>
this.counter.update(v => ({...v, count: v.count + increment})),
speed)
}
}
When tick is called, clear any existing intervals and create the new one.
up
is true, leave the diff (count by) as is, otherwise set it to its negative valueincrement
speed
(a number in milliseconds)This example highlights the low learning curve of signals compared to rjxs.
With signals, if we want to create derived state or run a side effect, we simply use computed
or effect
. If we want to add a dependency, we reference it in the callback. If we want to use that value without adding a dependency, we only reference it inside of untracked
. Those effects and computed execute their callback when their dependencies change based on a simple equality operation ===
.
// RxJS 🐉
const commandFromTick$ = counterUpdateTrigger$.pipe(
withLatestFrom(counterState$, (_, counterState) => ({
[ConterStateKeys.count]: counterState.count,
[ConterStateKeys.countUp]: counterState.countUp,
[ConterStateKeys.countDiff]: counterState.countDiff,
})),
tap(({ count, countUp, countDiff }) =>
programmaticCommandSubject.next({
count: count + countDiff * (countUp ? 1 : -1),
}),
),
);
In the rxjs version, let’s try to follow the commandFromTick$
logic (see source for full code)
counterUpdateTrigger$
is an Observable created via a switchMap
to timer
or NEVER
.combineLatest
of observables (which are created from our counterState$
observable).counterState$
observable via withLatestFrom
.programmaticCommandSubject
.counterCommands$
which triggers the original counterState$
.This is hard to follow. Because we have numerous observables and they are chained together in a loop of updates (only to be stopped by NEVER
).
The complete project. Only about 30 lines of typescript in our class and minimal imports / concepts. A few takeaways
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
signal,
untracked,
} from "@angular/core";
const DEFAULT_COUNTER = {
count: 0,
ticking: false,
speed: 1000,
up: true,
diff: 1,
adhocCount: 10,
};
@Component({
selector: "app-counter",
templateUrl: "./counter.component.html",
styleUrls: ["./counter.component.css"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterComponent {
counter = signal(DEFAULT_COUNTER);
ticking = computed(() => this.counter().ticking);
speed = computed(() => this.counter().speed);
diff = computed(() => this.counter().diff);
up = computed(() => this.counter().up);
adhocCount = computed(() => this.counter().adhocCount);
ticker = effect(() => {
const ticking = this.ticking();
const speed = this.speed();
const diff = this.diff();
const up = this.up();
untracked(() => this.tick(ticking, speed, diff, up));
});
start = () => this.counter.update((v) => ({ ...v, ticking: true }));
stop = () => this.counter.update((v) => ({ ...v, ticking: false }));
countUp = () => this.counter.update((v) => ({ ...v, up: true }));
countDown = () => this.counter.update((v) => ({ ...v, up: false }));
incrementBy = (diff: number) => this.counter.update((v) => ({ ...v, diff }));
setSpeed = (speed: number) => this.counter.update((v) => ({ ...v, speed }));
setCount = (count: number) =>
this.counter.update((v) => ({ ...v, count, adhocCount: count }));
setAdhocCount = (count: number) =>
this.counter.update((v) => ({ ...v, adhocCount: count }));
reset = () => this.counter.set(DEFAULT_COUNTER);
interval: NodeJS.Timeout | undefined = undefined;
tick(ticking: boolean, speed: number, diff: number, up: boolean) {
clearInterval(this.interval);
if (ticking) {
const increment = up ? diff : diff * -1;
this.interval = setInterval(
() =>
this.counter.update((v) => ({ ...v, count: v.count + increment })),
speed,
);
}
}
}
Enjoyed this article? Content similar to this is available on Flotes as studyable Notebooks. Information is delivered like Anki-style flashcards. Allowing you to fill in blanks and evaluate difficultly, to maximize learning efficiency.