DEV Community

Cover image for Pushing Angular's Reactive Limits: Event Context & Toasts/Dialogs
Mike Pearson for This is Angular

Posted on

 

Pushing Angular's Reactive Limits: Event Context & Toasts/Dialogs

YouTube

In an imperative app, events trigger event handlers, which are containers of imperative code. This seems to be the most common approach in Angular apps, unfortunately. A typical app might have a function like this:

  onSubmit() {
    if (this.form.valid) {
      this.store.deleteMovies();
      this.headerService
        .searchMovies(this.form.value)
        .subscribe((data: any) => {
          // ...
          this.store.saveSearch(data.results);
          this.store.saveSearchHeader(this.form.value);
          this.store.switchFlag(true);
          this.router.navigate(['/search']);
        });
    } else {
      swal({ // toast
        title: 'Incorrecto',
        // ...
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That event callback is controlling 5 pieces of state:

  • store.search
  • store.header
  • store.flag
  • the app's URL
  • the invalid toast

Before Diagram

This isn't great separation of concerns, because logic that controls each of those 5 pieces of state is scattered across many callbacks such as onSubmit, so to understand why anything is the way it is at a given time, you have to refer to many locations across the codebase. Basically, you need "Find All References", which can get really annoying.

So getting rid of event handlers/callbacks seems like a good potential place to start refactoring to more reactivity.

And what would the reactive version look like?

A simple rule for reactivity is this: Every user event in the template pushes the most minimal change to a single place in our TypeScript, then everything else reacts to that. Here's a diagram of that:

After Diagram

Not only is the code immediately downstream from the event source much simpler, but everything that can be downstream from something else is. This maximizes reactivity. In general, diagrams of reactive data flows will be taller and skinnier than diagrams of imperative data flows. You can actually see the improved separation of concerns.

(I haven't looked into this yet, but I suspect that even store.header and store.flag might be downstream from store.searchResults.)

The logic that controls each piece of state has been moved next to the state it controls. This makes it easier to avoid bugs, since we can easily refer to similar logic when writing new logic to control state.

Reactive Implementation

First, let's say we create a subject to represent the form submission:

search$ = new Subject<[boolean, string]>();
Enter fullscreen mode Exit fullscreen mode
        <input
          type="submit"
          value="Buscar"
          class="submit"
          (click)="search$.next([form.valid, form.value.search])"
        />
Enter fullscreen mode Exit fullscreen mode

Why did I give it a payload whereas the previous event handler onSubmit took no parameters?

Event Context

The Reason to pass the form data into the search$ event is flexibility: The previous event handler actually needed form.valid and form.value; it was just implicitly referenced. This makes it require refactoring if a developer wanted to move it to a service or something.

One problem people often have with reactive state management like StateAdapt or NgRx is that state is siloed into independent slices (or reducers, or stores). Sometimes in order to calculate a state change, you need to know the state of another state reducer/store. So what do you do? Refactor everything into one giant reducer/store?

The simple answer is to provide context with the event that is causing the state change.

Navigation

We now have 2 pieces of state immediately downstream from search$:

Search to URL and Toast

Like in the previous component, we need to split our stream into 2 pieces again. Again, I'll just use filter instead of partition.

Also... It's much more convenient to pass in a URL string and have app-navigate just react to that. In the last article I was thinking through this for the first time, and I believe I came up with an API that is less than ideal. So, rather than having a separate observable to trigger the router.navigate call, the mindset will be state-centric: The job of the router wrapper component is to ensure that the browser URL is in sync with the url input passed into it.

First, here's how I define the observables that chain off of search$:

  search$ = new Subject<[boolean, string]>();

  searchIsInvalid$ = this.search$.pipe(map(([valid]) => !valid));
  url$ = this.search$.pipe(
    filter(([valid]) => valid),
    map(([, search]) => `search/${search}`)
  );
Enter fullscreen mode Exit fullscreen mode

Now I can feed url$ into the app-navigate component:

<app-navigate [url]="url$ | async"></app-navigate>
Enter fullscreen mode Exit fullscreen mode

Also, here's the new source code for that component:

import { Component, Input, SimpleChanges } from '@angular/core';
import { Router, RouterModule } from '@angular/router';

@Component({
  standalone: true,
  selector: 'app-navigate',
  template: '',
  imports: [RouterModule],
})
export class NavigateComponent {
  @Input() url: string | null = null;

  constructor(private router: Router) {}

  ngOnChanges(changes: SimpleChanges) {
    const urlChange = changes['url'];
    const newUrl = urlChange?.currentValue;
    if (newUrl) {
      this.router.navigate([newUrl]);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That's actually smaller and simpler than it used to be. I like it.

Now I'll refactor the component from the last article to map to a URL instead of selectedMovieNull$:

  url$ = this.store.movieSelected$.pipe(
    filter((movie) => movie == null),
    map(() => '/')
  );
Enter fullscreen mode Exit fullscreen mode

I added a route parameter to the search route, since search is being tracked there now.

And I just discovered that the store.header state was the search query string. So apparently I have merged it in with the URL, and I can get rid of it in the store. It actually was never being used anywhere anyway, it turns out. I love refactoring random projects from GitHub :)

Toasts

I'm only now realizing that swal actually means "Sweet Alert" and this is a modal being opened, not a toast. But it doesn't matter. Everything in the UI should be declarative, and this might as well be a toast.

So let's make a wrapper component we can pass into a swal function call:

import { Component, Input, SimpleChanges } from '@angular/core';
import { SwalParams } from 'sweetalert/typings/core';
import swal from 'sweetalert';

@Component({
  standalone: true,
  selector: 'app-swal',
  template: '',
})
export class SwalComponent {
  @Input() show: boolean | null = false;
  @Input() options: SwalParams[0] = {};

  ngOnChanges(changes: SimpleChanges) {
    const showChange = changes['show'];
    const newShow = showChange?.currentValue;
    if (newShow) {
      swal(this.options);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This could evolve over time, but this is good enough for our purposes.

Now I can use it in the template and the search/header component is completely converted to reactivity:

        <app-swal
          [options]="{
            title: 'Incorrecto',
            text: 'Debes ingresar al menos dos caracteres para hacer una búsqueda..',
            icon: 'warning',
            dangerMode: true
          }"
          [show]="searchIsInvalid$ | async"
        ></app-swal>
Enter fullscreen mode Exit fullscreen mode

This has a bug apparently. It only opens once, then never opens again. This is because we are not resetting the state we're passing into the swal wrapper component, so ngOnChanges is not reacting to it.

In order for this to work correctly, we'd need to handle a callback function from the swal alert and have it update an observable to be merged back into its input so a false value would be passed in.

I decided to go ahead and implement this. Here's the new swal wrapper component:

import {
  Component,
  EventEmitter,
  Input,
  Output,
  SimpleChanges,
} from '@angular/core';
import { SwalParams } from 'sweetalert/typings/core';
import swal from 'sweetalert';

@Component({
  standalone: true,
  selector: 'app-swal',
  template: '',
})
export class SwalComponent {
  @Input() show: boolean | null = false;
  @Input() options: SwalParams[0] = {};
  @Output() close = new EventEmitter<any>();

  ngOnChanges(changes: SimpleChanges) {
    const showChange = changes['show'];
    const newShow = showChange?.currentValue;
    if (newShow) {
      swal(this.options).then((value) => this.close.emit(value));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we need to change the state we pass in to always reflect the state of whether that alert is open.

In this case, the simplest way of tracking the dialog state I can think of is using StateAdapt. This is because it needs to react to an observable searchIsInvalid$ but also be able to be set directly from the template. Remember, from the template, something imperative always needs to happen anyway, so we might as well be direct with it.

To use StateAdapt, we first need this in our AppModule:

import { defaultStoreProvider } from '@state-adapt/angular';
// ...
  providers: [defaultStoreProvider],
Enter fullscreen mode Exit fullscreen mode

Now we can use it in the component like this:

import { booleanAdapter } from '@state-adapt/core/adapters';
import { toSource } from '@state-adapt/rxjs';
import { adapt } from '@state-adapt/angular';

// ...

  searchIsInvalid$ = this.search$.pipe(
    map(([valid]) => !valid),
    toSource('searchIsInvalid$')
  );
  invalidAlertOpen = adapt(
    ['invalidAlertOpen', false, booleanAdapter],
    this.searchIsInvalid$
  );
Enter fullscreen mode Exit fullscreen mode
        <app-swal
          [options]="{
            title: 'Incorrecto',
            text: 'Debes ingresar al menos dos caracteres para hacer una búsqueda..',
            icon: 'warning',
            dangerMode: true
          }"
          [show]="invalidAlertOpen.state$ | async"
          (close)="invalidAlertOpen.setFalse()"
        ></app-swal>
Enter fullscreen mode Exit fullscreen mode

And as an added benefit, we get this state tracked in Redux Devtools:

invalid-alert-redux-devtools

How weird is that? The first state tracked in Redux Devtools in this entire app is an alert dialog, and it wasn't even managed as state before.

Downstream NgRx/Component-Store Changes

Alright, the component is working great, now we need to move on to the next level downstream:

url$-to-results$-&-store

This is all going to be defined in app.store.ts, because that's what it affects.

First let's trigger deleteMovies when the route is first entered. There's some logic we previously defined that I'm going to split out so we can reuse it. Here's what I have now:

    // New
    const urlAfterNav$ = this.router.events.pipe(
      filter((event): event is NavigationEnd => event instanceof NavigationEnd),
      map((event) => event.url)
    );

    // From before:
    const urlFromMovieDetailToHome$ = urlAfterNav$.pipe(
      pairwise(),
      filter(
        ([before, after]) =>
          before === '/movie' && ['/home', '/'].includes(after)
      )
    );

    // New
    const searchRoute$ = urlAfterNav$.pipe(
      filter((url) => url.startsWith('/search'))
    );
Enter fullscreen mode Exit fullscreen mode

Nice.

I'm getting a bug as well. It turns out switchFlag needs to be called immediately too. So, I'm going to trigger both state reactions with this new observable:

    this.react<AppStore>(this, {
      deleteMovies: merge(urlFromMovieDetailToHome$, searchRoute$).pipe(
        map(() => undefined)
      ),
      switchFlag: merge(
        urlFromMovieDetailToHome$.pipe(map(() => false)),
        searchRoute$.pipe(map(() => true))
      ),
    });
Enter fullscreen mode Exit fullscreen mode

If you're confused about this block of code, see the last article where I explained react. Hopefully it's somewhat self-explanatory, but just know that the keys of the object passed in are names of state change functions in this NgRx/Component-Store, and they get called and passed the values emitted from the observables on the right-hand side.

Now we just need to define the results$ observable as chaining off of the search term in the URL:

    const searchResults$ = searchRoute$.pipe(
      switchMap((url) => {
        const [, search] = url.split('/search/');
        return this.headerService.searchMovies({ search });
      }),
      map((res: any) =>
        res.results.map((movie: any) => ({
          ...movie,
          poster_path:
            movie.poster_path !== null
              ? `${environment.imageUrl}${movie.poster_path}`
              : 'assets/no-image.png',
        }))
      )
    );
Enter fullscreen mode Exit fullscreen mode

This takes the URL, grabs the search query from it, passes it to the MovieService and loops through the results giving each movie a default poster image. This logic existed already, including those unfortunate any types. I don't feel like fixing that right now.

And we can plug this observable into the react method now:

saveSearch: searchResults$,
Enter fullscreen mode Exit fullscreen mode

saveSearch should probably be named receiveSearchResults, but I'm going to ignore that for now too.

Conclusion

Everything works again! And it's reactive now! Here's the final commit.

The main takeaway from this is the lesson I learned from creating the SwalComponent and refactoring the AppNavigateComponent: Angular inputs should represent state, not events. This is more declarative anyway, so I'm happy with the way these wrapper components are now.

Yes, it does take work to create these wrapper components and integrate them properly. But it enables a much more flexible data flow with better separation of concerns.

Previously, there were 11 imperative statements, and lots of mixed concerns, as you can see from this screenshot:

Before

The new reactive implementation only has 2 imperative statements, both from the template, so, unavoidable. But the reactive data flow introduced better separation of concerns, as you can see:

after-header
after-store

Notice here that we now have multiple sources of state changes in one place, including the state changes we added when refactoring the first component. At the end of this refactor project, maybe I should come up with a way to show visually how across multiple files, state change logic became centralized next to the state it was changing. That might be cool.

Another thing to notice is that I'm not showing all the code I created while refactoring. That is because it is represented in the variable name instead. If you look at just this line:

      saveSearch: searchResults$,
Enter fullscreen mode Exit fullscreen mode

You know that the state will change as described by saveSearch when searchResults$ emits. The name searchResults$ tells you what you need to know, and if you want to know more details, you can use "Click to Definition" and look at it.

Everything that will cause search results to be updated will be right here. So far this is the only one. But if we add another, it might look like

      saveSearch: merge(searchResults$, moreSearchResults$),
Enter fullscreen mode Exit fullscreen mode

On the other hand, the only way to know why search results changed in an imperative project would be to use Find All References on the saveSearch method. You don't even know the names of the callback functions that are controlling it until you do that.

Not only that, but a callback function like onSubmit gives you no clue what it actually does or what state it modifies. It's a bad name for a function; ideally you could have something in the template like (submit)="saveToServer()", but what if that's not the only thing that has to happen? With callback functions, which are containers of imperative code, you can only give it the name of the commonality of all of its effects. It happens to be that quite often the only name people can come up with is the circumstance or context in which the callback function is called. This violates Clean Code's advice for good function names, which says that functions should be named for what they do.

Good names come more easily with declarative programming, because the things you have to name are only concerned with themselves.

So, how much code was that? git diff --stat says 79 insertions and 62 deletions.

I'm beginning to doubt that this will be less code by the end. I increased the lines of code a lot when I changed

      swal({
        title: 'Incorrecto',
        text: 'Debes ingresar al menos dos caracteres para hacer una búsqueda..',
        icon: 'warning',
        dangerMode: true,
      });
Enter fullscreen mode Exit fullscreen mode

to

  searchIsInvalid$ = this.search$.pipe(
    map(([valid]) => !valid),
    toSource('searchIsInvalid$')
  );
  invalidAlertOpen = adapt(
    ['invalidAlertOpen', false, booleanAdapter],
    this.searchIsInvalid$
  );
Enter fullscreen mode Exit fullscreen mode

and

        <app-swal
          [options]="{
            title: 'Incorrecto',
            text: 'Debes ingresar al menos dos caracteres para hacer una búsqueda..',
            icon: 'warning',
            dangerMode: true
          }"
          [show]="invalidAlertOpen.state$ | async"
          (close)="invalidAlertOpen.setFalse()"
        ></app-swal>
Enter fullscreen mode Exit fullscreen mode

That's going from 6 to 18 lines of code. That's most of the difference, actually.

But since we now have something in Redux Devtools, and we separated the concern of the dialog from the other updates that onSubmit was burdened with, I think this was worth it.

I guess we'll see how things compare in the end! There are still 2-3 components to refactor.

Top comments (0)

One Million Strong

We are an active and inclusive community of over one million registered creators, developers, and tech enthusiasts. Join us.