Heavily Dynamic UIs (Angular)

RxJS Operating Heavily Dynamic UIs - Rewritten with Angular Signals
Article Preview
// 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?

  • The signal version is minimal in concepts and code
  • The RxJS example uses 15+ imports/keywords/concepts
  • Observable logic can be difficult to follow even when well written
  • i.e. Signals can simplify our code

If you’re not familiar with Angular signals see these previous articles

For the original rxjs source code

Lets break it down.

For the complete code 🚀 Source Code on Github | 🚀 Demo

Counter with Angular Symbol

A Basic Starting Point

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.

  • It’s important to clear the interval to handle stop & future state changes.
  • untracked prevents signals in tick from being added as dependencies
import {  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

  • The signals API is small and effective
  • We use basic Typescript and native Web APIs where possible

In the next sections we’ll start to compare some of our signal code to the original rxjs code.

Making a Heavily Dynamic UI

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

  • Create a computed to get the primitive value as a signal
  • Bind the result of computed to the element value [value]="speed()"
  • Bind the change event to the update function (change)="setSpeed(+speedEle.value)"

Comparison

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)

Wiring up the Counter

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.

  • If up is true, leave the diff (count by) as is, otherwise set it to its negative value
  • update the count by increment
  • declare the interval speed as speed (a number in milliseconds)

Comparison

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.
  • It’s sourced from a combineLatest of observables (which are created from our counterState$ observable).
  • We utilize the original counterState$ observable via withLatestFrom.
  • On emission, we call next on programmaticCommandSubject.
  • That subject triggers updates to 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).

Review

The complete project. Only about 30 lines of typescript in our class and minimal imports / concepts. A few takeaways

  • Works with OnPush
  • Minimal imports / concepts (all of which are from @angular/core)
  • Rinse and repeat patterns to implement functionality
  • Ticker is invoked optimally with minimal effort / complexity.
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)
    }
  }
}

More Angular Signals

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.

📡 Angular Advanced Signals

Thu Oct 26 2023