This series examines how we can preserve declarative code as our features move through increasing levels of complexity.

Level 2: Simple Derived State

Imagine that we now need to display the color names with the first letter capitalized.

Color Picker—Simple Derived State

The button text isn't an issue since it's static, but the content within #color-preview depends on the current selection. This introduces two distinct state values: the raw value aqua and its formatted counterpart Aqua, or perhaps currentColor and currentColorName.

The Pull of Imperative Code

One approach would be to modify the click bindings from (click)="currentColor = 'aqua'" to (click)="currentColor = 'aqua'; currentColorName = 'Aqua". However, this duplicates logic across every click event and adds unwanted clutter to the template. Moreover, Angular templates lack full JavaScript support.

This might push us towards a dedicated method:

export class ColorPickerComponent {
  currentColor = 'aqua';
  currentColorName = 'Aqua';

  changeColor(newColor: string) {
    this.currentColor = newColor;
    this.currentColorName = newColor.charAt(0).toUpperCase()
      + newColor.slice(1);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

But this route introduces two imperative assignments (currentColor and currentColorName) that are detached from their declarations, plus the changeColor() method is invoked from three different spots in the template. That makes for a total of five imperative statements. In the previous step, we were updating currentColor directly in the template by necessity, which was just three imperative statements. We should aim to maintain that minimal footprint.

The template should trigger the smallest possible change—updating only currentColor. Then, currentColorName should automatically respond to that change, mimicking the reactive behavior the template already demonstrates.

Blind Alleys in Syntax

Couldn't we just rely on Angular pipes? A quick solution like {{currentColor | titlecase}} in the template seems like it would solve everything!

In this particular case, I likely would use it, as titlecase is built into Angular's CommonModule and requires no extra setup or configuration.

That said, I've moved away from crafting custom pipes for quite some time, and here's my reasoning:

  • Creating an injectable class, registering it in a module, and adding it to the template for a basic transformation feels overly heavy.
  • Despite Angular's change detection cutting down on some redundant computations, performance isn't a major concern at this level. But as complexity and performance demands rise, the optimal path is to disable change detection and lean on RxJS. Additionally, unlike memoized selectors, pipes don't seem to cache results when the same transformation is applied to the same value in multiple template locations.
  • Pipes tend to push more logic into templates. With growing complexity, you'll often find chains like value | pipe1 | pipe2 | pipe3, and that chain itself becomes logic you'd want to reuse. RxJS pipelines are more reusable, and it's simpler to migrate synchronous RxJS pipe logic into memoized selectors.

Angular pipes don't handle scaling well in comparison to RxJS, and transitioning from pipes to RxJS involves substantial rewriting. This makes pipes a syntactic blind alley.

A Reactive Approach for Level 2

For this stage of complexity, RxJS is the ideal fit:

export class ColorPickerComponent {
  currentColor$ = new BehaviorSubject('aqua');
  currentColorName$ = this.currentColor$.pipe(
    map(color => color.charAt(0).toUpperCase() + color.slice(1)),
  );
}
Enter fullscreen mode Exit fullscreen mode

The declaration for currentColorName$ is now consolidated in a single spot!

Migrating the template is straightforward with the async pipe. A useful pattern is to wrap the content in an ng-container and assign the pipe's output to a template variable:

<ng-container *ngIf="currentColor$ | async as currentColor">
...
</ng-container>
Enter fullscreen mode Exit fullscreen mode

(Be sure to look into NgRx's ngrxLet directive! It offers better performance and handles the value 0 correctly, a situation where ngIf falls short.)

The click handlers now shift from (click)="currentColor = 'aqua'" to (click)="currentColor$.next('aqua')", which is a simple adjustment. Within #color-preview, you'll use {{ currentColorName$ | async}} to display the value.


Let's pause and reflect on the insights from the first two levels of complexity.

To avoid syntactic dead ends, we should be cautious about overloading templates with logic, as that presents the least flexibility for future adaptations.

Our imperative code avoidance goal still holds true: Each user interaction in the template should trigger a single, minimal change in our TypeScript, and all subsequent state should naturally derive from that change.

But before codifying this principle, note that in both the imperative vanilla JS and Angular versions, a function was the container for the imperative logic. These were event handlers or callbacks without return values. The templates outsourced their operations to the broad and rigid changeColor function.

What if we eliminated callback functions entirely? This turns out to be a more universal and effective guideline.


divider

Progressive Reactivity Rule #2:

Avoid writing callback functions.

Refrain from crafting callback functions, including DOM event handlers. Try to sidestep Angular's lifecycle callbacks as well. When you encounter a pattern like this:

doStuff(x: any) {
  // Do stuff
}
Enter fullscreen mode Exit fullscreen mode

Pause and consider whether the invoking entity could simply make a single small adjustment instead, allowing everything else to respond reactively:

x$.next(x);
// Now we mind our own business as
// everything else automatically updates
Enter fullscreen mode Exit fullscreen mode

Does this sound radical? Aren't methods necessary for handling future flexibility?

Not really. When do we actually add more code to a callback? It's when we're tempted to write imperative logic. So, it's better to avoid callbacks altogether. The empty braces of a void function are an open invitation for imperative code.

Even if you must call an imperative API, adding a tap(...) to your RxJS pipeline doesn't require much syntactic overhead. However, keep in mind that both tap and subscribe accept callbacks for imperative work, so minimize their use as well.

There will be times when writing callback functions is the only way to interface with imperative APIs. Don't stress over it. Just also keep Rule 3 from this series in mind for those cases.

divider


The next post in this series continues with Level 3: Complex Changes and Derived State.