DEV Community

Cover image for Application State Management with Angular Signals
Evgeniy OZ
Evgeniy OZ

Posted on • Updated on • Originally published at Medium

Application State Management with Angular Signals

In this article, I will demonstrate how to manage your application's state using only Angular Signals and a small function.

More than "Service with a Subject"

Let's begin with an explanation of why using a bunch of BehaviorSubject objects inside a service is not enough to manage state modifications caused by asynchronous events.

In the code below, we have a method saveItems() that will call the API service, to update the list of items asynchronously:

saveItems(items: Item[]) {
  this.apiService.saveItems(items).pipe(
    takeUntilDestroyed(this.destroyRef)
  ).subscribe((items) => this.items$.next(items));
}
Enter fullscreen mode Exit fullscreen mode

Every time we call this method, we are taking a risk.

Example: Let's say we have two requests, A and B. 

Request A started at time 0s 0ms, and Request B started at 0s 250ms. However, due to some issue, the API responded to A after 500ms, and to B after 150ms. 

As a result, A was completed at 0s 500ms, and B at 0s 400ms. 
This can lead to the wrong set of items being saved.

It also works with GET requests - sometimes it's pretty important, what filter you should be applied to your search request.

We could add some check like this:

saveItems(items: Item[]) {
  if (this.isSaving) {
    return;
  }
  this.isSaving = true;
  this.apiService.saveItems(items).pipe(
    finalize(() => this.isSaving = false),
    takeUntilDestroyed(this.destroyRef)
  ).subscribe((items) => this.items$.next(items));
}
Enter fullscreen mode Exit fullscreen mode

But then the correct set of items will have no chance to be saved at all.

That's why we need effects in our stores.

Using NgRx ComponentStore, we could write this:

 readonly saveItems = this.effect<Item[]>(_ => _.pipe(
   concatMap((items) => this.apiService.saveItems(items)),
   tapResponse(
     (items)=> this.items$.next(items),
     (err) => this.notify.error(err)
   )
));
Enter fullscreen mode Exit fullscreen mode

Here you can be sure that requests will be executed one after another, no matter how long each of them will run.

And here you can easily pick a strategy for request queuing: switchMap(), concatMap(), exhaustMap(), or mergeMap().

Signal-based Store

What is an Application State? An Application State is a collection of variables that define how the application should look and behave.

An application always has some state, and Angular Signals always have a value. It's a perfect match, so let's use signals to keep the state of our application and components.

class App {
   $users = signal<User[]>([]);
   $loadingUsers = signal<boolean>(false);
   $darkMode = signal<boolean|undefined>(undefined);
}
Enter fullscreen mode Exit fullscreen mode

It is a simple concept, but there is one issue: anyone can write to $loadingUsers. Let's make our state read-only to avoid infinite spinners and other bugs that globally writable variables can bring:

class App {
   private readonly state = {
     $users: signal<User[]>([]),
     $loadingUsers: signal<boolean>(false),
     $darkMode: signal<boolean|undefined>(undefined),
   } as const;

   readonly $users = this.state.$users.asReadonly();
   readonly $loadingUsers = this.state.$loadingUsers.asReadonly();
   readonly $darkMode = this.state.$darkMode.asReadonly();

   setDarkMode(dark: boolean) {
     this.state.$darkMode.set(!!dark);
   }
}
Enter fullscreen mode Exit fullscreen mode

Yes, we wrote more lines, but otherwise, we would have to use getters and setters, and it's even more lines. No, we can not just leave them all writeable and add some comment "DO NOT WRITE!!!" 😉

In this store, our read-only signals (including signals, created using computed()) are the replacement for both: state and selectors.

The only thing left: we need effects, to mutate our state.

There is a function in Angular Signals, named effect(), but it only reacts to the changes in signals, and pretty often we should modify the state after some request(s) to the API, or as a reaction to some asynchronously emitted event. While we could use toSignal() to create additional fields and then watch these signals in Angular's effect(), it still wouldn't give us as much control over asynchronous code as we want (no switchMap(), no concatMap(), no debounceTime(), and many other things).

But let's take a well-known, well-tested function, with an awesome and powerful API: ComponentStore.effect() and make it standalone!

createEffect()

Using this link, you can get the code of the modified function. It's short, but don't worry if you can't understand how it works under the hood (it takes some time): you can read the documentation on how to use the original effect() method here: NgRx Docs, and use createEffect() the same way.

Without typing annotations, it is quite small:

function createEffect(generator) {
  const destroyRef = inject(DestroyRef);
  const origin$ = new Subject();
  generator(origin$).pipe(
    retry(),
    takeUntilDestroyed(destroyRef)
  ).subscribe();

  return ((observableOrValue) => {
    const observable$ = isObservable(observableOrValue)
      ? observableOrValue.pipe(retry())
      : of(observableOrValue);
    return observable$.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => {
      origin$.next(value);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

It was named createEffect() to don't interfere with Angular's effect() function.

Modifications:

  • createEffect() is a standalone function. Under the hood, it subscribes to an observable, and because of that createEffect() can only be called in an injection context. That's exactly how we were using the original effect() method;
  • createEffect() function will resubscribe on errors, which means that it will not break if you forget to add catchError() to your API request.

And, of course, feel free to add your modifications :)

Put this function somewhere in your project, and now you can manage the application state without any additional libraries: Angular Signals + createEffect().

Store Types

There are three types of stores:

  1. Global Store (application level) - accessible to every component and service in your application;
  2. Feature Store ("feature" level) - accessible to the descendants of some particular feature;
  3. Local Store (a.k.a "Component Store") - not shared, every component creates a new instance, and this instance will be destroyed when the component is destroyed.

I wrote an example application to show you how to implement a store of every type using Angular Signals and createEffect(). I'll use stores and components (without templates) from that application to let you see the code examples in this article. The whole code of this app you can find here: GitHub link.

Global Store

@Injectable({ providedIn: 'root' })
export class AppStore {
  private readonly state = {
    $planes: signal<Item[]>([]),
    $ships: signal<Item[]>([]),
    $loadingPlanes: signal<boolean>(false),
    $loadingShips: signal<boolean>(false),
  } as const;

  public readonly $planes = this.state.$planes.asReadonly();
  public readonly $ships = this.state.$ships.asReadonly();
  public readonly $loadingPlanes = this.state.$loadingPlanes.asReadonly();
  public readonly $loadingShips = this.state.$loadingShips.asReadonly();
  public readonly $loading = computed(() => this.$loadingPlanes() || this.$loadingShips());

  constructor() {
    this.generateAll();
  }

  generateAll() {
    this.generateA();
    this.generateB();
  }

  private generateA = createEffect(_ => _.pipe(
    concatMap(() => {
      this.state.$loadingPlanes.set(true);
      return timer(3000).pipe(
        finalize(() => this.state.$loadingPlanes.set(false)),
        tap(() => this.state.$planes.set(getRandomItems()))
      )
    })
  ));

  private generateB = createEffect(_ => _.pipe(
    exhaustMap(() => {
      this.state.$loadingShips.set(true);
      return timer(3000).pipe(
        finalize(() => this.state.$loadingShips.set(false)),
        tap(() => this.state.$ships.set(getRandomItems()))
      )
    })
  ));
}
Enter fullscreen mode Exit fullscreen mode

To create a global store, add this decorator: 
@Injectable({ providedIn: 'root' })

Here, you can see that every time you click the big purple button "Reload," both lists, "planes" and "ships," will be reloaded. The difference is that "planes" will be loaded consecutively, as many times as you clicked the button. "Ships" will be loaded just once, and all consecutive clicks will be ignored until the previous request is completed.

Feature Store

@Injectable()
export class PlanesStore {
  private readonly appStore = inject(AppStore);
  private readonly state = {
    $page: signal<number>(0),
    $pageSize: signal<number>(10),
    $displayDescriptions: signal<boolean>(false),
  } as const;

  public readonly $items = this.appStore.$planes;
  public readonly $loading = this.appStore.$loadingPlanes;
  public readonly $page = this.state.$page.asReadonly();
  public readonly $pageSize = this.state.$pageSize.asReadonly();
  public readonly $displayDescriptions = this.state.$displayDescriptions.asReadonly();

  public readonly paginated = createEffect<PageEvent>(_ => _.pipe(
    debounceTime(200),
    tap((event) => {
      this.state.$page.set(event.pageIndex);
      this.state.$pageSize.set(event.pageSize);
    })
  ));

  setDisplayDescriptions(display: boolean) {
    this.state.$displayDescriptions.set(display);
  }
}
Enter fullscreen mode Exit fullscreen mode

The root component (or a route) of the feature should "provide" this store:

@Component({
  // ...
  providers: [
    PlanesStore
  ]
})
export class PlanesComponent { ... }
Enter fullscreen mode Exit fullscreen mode

Do not add this store to the providers of descendant components, otherwise, they will create their own, local instances of the feature store, and it will lead to unpleasant bugs.

Local Store

@Injectable()
export class ItemsListStore {
  public readonly $allItems = signal<Item[]>([]);

  public readonly $page = signal<number>(0);

  public readonly $pageSize = signal<number>(10);

  public readonly $items: Signal<Item[]> = computed(() => {
    const pageSize = this.$pageSize();
    const offset = this.$page() * pageSize;
    return this.$allItems().slice(offset, offset + pageSize);
  });

  public readonly $total: Signal<number> = computed(() => this.$allItems().length);

  public readonly $selectedItem = signal<Item | undefined>(undefined);

  public readonly setSelected = createEffect<{
    item: Item,
    selected: boolean
  }>(_ => _.pipe(
    tap(({ item, selected }) => {
      if (selected) {
        this.$selectedItem.set(item);
      } else {
        if (this.$selectedItem() === item) {
          this.$selectedItem.set(undefined);
        }
      }
    })
  ));
}
Enter fullscreen mode Exit fullscreen mode

Pretty similar to a feature store, the component should provide this store to itself:

@Component({
  selector: 'items-list',
  // ...
  providers: [
    ItemsListStore
  ]
})
export class ItemsListComponent { ... }
Enter fullscreen mode Exit fullscreen mode

Component as a Store

What if our component is not so big and we are sure that it will remain not so big, and we just don't want to create a store for this small component?

I have an example of a component, written this way:

@Component({
  selector: 'list-progress',
  // ...
})
export class ListProgressComponent {
  protected readonly $total = signal<number>(0);
  protected readonly $page = signal<number>(0);
  protected readonly $pageSize = signal<number>(10);

  protected readonly $progress: Signal<number> = computed(() => {
    if (this.$pageSize() < 1 && this.$total() < 1) {
      return 0;
    }
    return 100 * (this.$page() / (this.$total() / this.$pageSize()));
  });

  @Input({ required: true })
  set total(total: number) {
    this.$total.set(total);
  }

  @Input() set page(page: number) {
    this.$page.set(page);
  }

  @Input() set pageSize(pageSize: number) {
    this.$pageSize.set(pageSize);
  }

  @Input() disabled: boolean = false;
}
Enter fullscreen mode Exit fullscreen mode

In version 17 of Angular, the input() function will be introduced to create inputs as signals, making this code much shorter.

This example application is deployed here: GitHub Pages link.

Image description

You can play with it to see how the state of different lists is independent, how the feature state is shared across the components of a feature, and how all of them use the lists from the application's global state.

I know we could improve the code and make things better - but it's not the point of this example app. All the code here has only one purpose: to illustrate this article and to explain how things might work.

I've demonstrated how to manage an Angular application state without third-party libraries, using only Angular Signals and one additional function. 
Thank you for reading!


💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.

🎩️ If you or your company is looking for an Angular consultant, you can purchase my consultations on Upwork.

Top comments (0)