RxJS can save your codebase

Mike Pearson - Jul 24 '23 - - Dev Community

YouTube

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):

Image description

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));
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
Enter fullscreen mode Exit fullscreen mode

Becomes this with signals:

            if (count > 40) {
              this.clearInterval();
              this.state.set('blinking');
              this.runState.set({ count: 1, side: 'r' });
            }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

If you Cmd Click on dragonClass$, you see this:

  dragonClass$ = concat(
    this.runningClass$,
    this.blinkingEyesClass$,
    this.restingClass$
  ).pipe(repeat(Infinity));
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Non-Declarative

<div id="count">0</div>
Enter fullscreen mode Exit fullscreen mode
countElement.innerText = +countElement.innerText + 1;
Enter fullscreen mode Exit fullscreen mode

Variables

Declarative

const count = 0;
Enter fullscreen mode Exit fullscreen mode

Non-Declarative

let count = 0;

// ...

  count++;
Enter fullscreen mode Exit fullscreen mode

State

Declarative

const count$ = increment$.pipe(scan(n => n + 1, 0));

const count = toSignal(count$);
Enter fullscreen mode Exit fullscreen mode

Non-Declarative

const count = signal(0);

// ...

  count.set(count() + 1);
Enter fullscreen mode Exit fullscreen mode

Effect Events

Declarative

const increment$ = incrementRequest$.pipe(
  mergeMap(() => server.increment()),
);
Enter fullscreen mode Exit fullscreen mode

Non-Declarative

const increment = createAction('INCREMENT');

// ...

increment$ = this.actions$.pipe(
  ofType('INCREMENT_REQUEST'),
  mergeMage(() => server.increment()),
  map(() => increment()),
);
Enter fullscreen mode Exit fullscreen mode

(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');
Enter fullscreen mode Exit fullscreen mode

Non-Declarative

const incrementRequest$ = Subject<void>();

// ...

  incrementRequest$.next();
Enter fullscreen mode Exit fullscreen mode

Benefits of declarative code

Focus

Image description

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

Image description

Image description

When behavior is being controlled from one place, rather than from many places, bugs are easier to track down.

Avoiding Bugs

Image description

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

Image description

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:

Image description

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));
Enter fullscreen mode Exit fullscreen mode

Image description

const b$ = a$.pipe(debounceTime(10));
Enter fullscreen mode Exit fullscreen mode

Image description

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;
}
Enter fullscreen mode Exit fullscreen mode

This can be used like this:

count = signal(0);

delayedCount = delay(this.count, 3000);
Enter fullscreen mode Exit fullscreen mode

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

  1. reorganize the code to use a computed
  2. use an existing signal operator
  3. write a new custom signal operator
  4. 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.

  1. Use computed.
  2. Use a signal operator.
  3. Create a signal operator.
  4. Use RxJS.

Thanks for reading! Follow me on Twitter for more frequent content: https://twitter.com/mfpears

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player