This series examines how we can maintain a declarative approach in our code as our features evolve through increasing levels of complexity.

Level 3: Complex Modifications and Derived State

Now that we've adopted RxJS, it's important to recognize that RxJS is extremely capable. It can manage virtually anything, even scenarios where it might not be the ideal choice. Without careful attention, our seemingly straightforward RxJS pipes can expand into a sprawling, unwieldy structure that drives our colleagues to vent about RxJS on Twitter.

There isn't a crisp boundary separating "appropriate for RxJS" from "overly complex for RxJS." However, these indicators suggest you've reached Level 3 complexity:

  • You're resorting to a tap operator with console.log to troubleshoot a stream. This signals a need for devtools.
  • You're using distinctUntilChanged, share or combineLatest along with other merge operators. This points to a need for memoized selectors.
  • You wish you could spread objects and arrays directly in templates to feed into behaviorSubject$.next(...), or you're tempted to write methods that imperatively trigger those modifications from elsewhere. This indicates you need a centralized set of operations that define how your complex object/array can be altered. RxJS can handle this through scan(), though it involves substantial boilerplate.

So what we're looking for is:

  • Devtools
  • Selectors
  • Centralized, declarative state transitions

This is beginning to resemble a state management solution. However, let's hold off on exploring libraries for now. Instead, our objective here is to design syntax we consider optimal as we progress, and only after we've addressed every complexity level will we examine the landscape of state management libraries and determine which align best with our "progressive reactivity" approach.

Full transparency: I developed a state management library called StateAdapt, and I've been refining its syntax as I write these articles. The true motivation behind this series is to shape my decision-making throughout this process. Consequently, if you subscribe to the principles outlined in the series introduction, StateAdapt will likely offer syntax that appeals to you. Yet, with only 21 stars on GitHub as of July 2022, it clearly falls under the "hobby project" category, so I wouldn't advise using it in production. Given that I can't endorse its use, I'll make a deliberate effort to categorize the mainstream alternatives at the conclusion of this series, ensuring you know the strongest options for different situations, assuming you're aiming for progressive reactivity without running into syntactic limitations.

Let's proceed. We need to brainstorm syntax that is only marginally more complex than a BehaviorSubject but perfectly suited for Level 3 complexity.

Let's transform our color picker into a collection of color pickers:

Color Picker—Complex Changes and Derived State

Initially, we'll refactor our existing color-picker into a component with the following inputs and outputs:

@Input() color = 'aqua';
@Input() colorName = 'Aqua';
@Output() colorChange = new EventEmitter<string>();
Enter fullscreen mode Exit fullscreen mode

Our template requires minimal adjustments. We simply eliminate the | async pipes and perform some straightforward renaming. Here's the StackBlitz demo.

Next, we need a parent component that uses an *ngFor to render the list of colors. It will maintain a central colors array:

export class ColorsComponent {
  colors$ = new BehaviorSubject(['aqua', 'aqua', 'aqua']);
}
Enter fullscreen mode Exit fullscreen mode

But how do we update a specific color? Should we compute the new state directly in the template with something like colors$.next([$event, colors[1], colors[2])? That approach isn't declarative. Additionally, it's inelegant. It also wouldn't be passing minimal information from the template to TypeScript—the minimal payload would be [newColor, index]. We also want to avoid creating a callback function that imperatively modifies colors$ (refer to Rule 2). Instead, we're aiming for a state container (or store) structured like this:

export class ColorsComponent {
  store = createStore(['aqua', 'aqua', 'aqua'], {
    changeColor: (state, [newColor, index]: [string, number]) =>
      state.map((color, i) => i === index ? newColor : color),
  });
}
Enter fullscreen mode Exit fullscreen mode

By defining the state and its possible mutations together, we gain type inference. This approach is also declarative, making it easier for developers to understand the intended behavior.

What about selectors? The component also relies on a colorName property, so we should create a selector for that derived state. The most convenient option would be to define it alongside the state under a selectors property. I'm also quite fond of NGXS's convention of naming selectors as nouns. Let's adopt that style.

It would also be excellent if createStore returned a store object containing all state change methods, along with observables for every selector bundled together, enabling usage in the component like this:

<app-color-picker
  *ngFor="let color of store.colors$ | async; index as i"
  [color]="color.value"
  [colorName]="color.name"
  (colorChange)="store.changeColor([$event, i])"
></app-color-picker>
Enter fullscreen mode Exit fullscreen mode

And what about devtools? If we supply a string to namespace this feature, perhaps createStore can manage everything seamlessly in the background.

So, combining all these elements, here's the syntax I've devised:

export class ColorsComponent {
  store = createStore(['colors', ['aqua', 'aqua', 'aqua']], {
    changeColor: (colors, [newColor, index]: [string, number]) =>
      colors.map((color, i) => i === index ? newColor : color),
    selectors: {
      colors: state => state.map(color => ({
        value: color,
        name: color.charAt(0).toUpperCase() + color.slice(1),
      })),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

This syntax works well because

  • we avoid generating separate state management files for state that remains fairly basic. It's only a step beyond a BehaviorSubject, and it's suffices.
  • It functions identically when sharing with other components—just relocate it to a service.
  • It's entirely declarative for Level 3 complexity. User actions transmit only the necessary data to the self-contained store, and all state change logic resides in a single location.

Consolidating all this logic also unlocks a pattern that's often considered advanced but ought to be far more widespread: reusable state patterns! That topic will be covered in the next article of this series.