close icon
Angular

State Management in Angular Using Akita - Pt. 1

See how it's easy to manage your Angular application's state using Akita and learn how you can use Akita with Auth0's SDK to handle user-related functionalities.

February 10, 2022

Intro

State management is a key component when building applications. There are various approaches by which we can manage the state in an Angular application, each with its pros and cons. This blog post will focus on using Akita as our state management solution. We will look at how you can use Akita to manage your application's state by building a Recipe Admin Dashboard application. We will also learn how to secure the application using Auth0 and how it works with Akita.

What Is Akita

Akita is a state management pattern based on object-oriented design principles. Akita is built on top of RxJs, merging the multiple data stores concept from Flux, immutable updates from Redux, and data streams from RxJs to create the Observable Data Store model. Akita boasts an opinionated structure that forces developers to follow a fixed pattern that cannot be deviated from, encouraging a uniform implementation in your codebase.

How Does Akita Work

Akita is made up of 4 main components - Store, Actions, Query, and Effects.

Akita uses the Redux concept of unidirectional data flow, where all application data goes through the same lifecycle. This unidirectional data flow makes the application's state more predictable and thus easier to understand. This flow only applies to the state management layer and is not to be confused with the unidirectional data flow of the presentation layer. The following diagram shows the state management lifecycle in Akita.

Akita State Management Lifecycle Diagram

Store

The Store in Akita contains a single object containing the store state and acts as the application's single source of truth. It reflects the current state of the app. You can think of this as a client-side database.

Actions

Actions express unique events that happen in our application. These events range from application lifecycle events, user interactions, to network requests. Actions are how the application communicates with Akita's store to tell it what to do.

Query

Queries are similar to database queries. A query is responsible for querying or getting slices of the state from the Store. Queries are how our application listens to state changes.

Effects

Effects handle the side effects of each Action. These side effects range from communicating with an external API via HTTP when a certain Action is dispatched to dispatching another Action to update another part of the State.

Prerequisites

Angular requires an active LTS or maintenance LTS version of Node.js. Angular applications also depend on npm packages for many features and functions. To download and install npm packages, you need an npm package manager such as npm or yarn.

This project has a server-side component that has to run in parallel when running the Frontend. Follow the instructions in the Api Express Typescript Menu repo. You can read more about setting up the server-side with Auth0 in this blog post.

This tutorial focuses on how to use Auth0 with Akita. For more information on setting up Auth0 for Angular applications, follow the instructions on the README or refer to this blog post.

Getting Started Quickly

I created a demo application with the basic structure and components to help you implement the Akita-related part.

Clone the demo app and check out its starter branch:

git clone -b starter git@github.com:auth0-blog/spa_angular_typescript_dashboard.git

Once you clone the repo, make spa_angular_typescript_dashboard your current directory:

cd spa_angular_typescript_dashboard

Install the project's dependencies:

npm i

Run the project locally:

npm run start

Devtools

We will be using the redux devtools extension for Chrome or Firefox for debugging store-related operations.

To use this extension with Akita, you'll need to add Akita's devtools dependency to our project. You can do this using npm.

npm i @datorama/akita-ngdevtools --save

Import the AkitaNgDevtools in our AppModule and configure it based on your project's requirements. For this tutorial, you'll be using their default configuration. Open app.module.ts and add the following code 👇

// src/app/app.module.ts

import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { HttpClientModule, HTTP_INTERCEPTORS } from "@angular/common/http";
import { AuthHttpInterceptor, AuthModule } from "@auth0/auth0-angular";

// ✨ New 👇
import { AkitaNgDevtools } from "@datorama/akita-ngdevtools";

import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { NavBarModule } from "./shared";
import { environment } from "src/environments/environment";

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    AuthModule.forRoot({
      ...environment.auth,
      httpInterceptor: {
        allowedList: [
          `${environment.serverUrl}/api/menu/items`,
          `${environment.serverUrl}/api/menu/items/*`,
        ],
      },
    }),
    AppRoutingModule,
    NavBarModule,

    // ✨ New 👇
    environment.production
      ? []
      : AkitaNgDevtools.forRoot({
          maxAge: 25,
        }),
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthHttpInterceptor,
      multi: true,
    },
  ],
})
export class AppModule {}

Running the app and turning on devtools

After following the steps in this section, you should see an option to activate Redux Devtools in your toolbar when you run the app. Once activated, you should see a window with an interface similar to the image below.

Redux Devtools with Akita

You can learn more about these features from their official documentation.

Install Akita

You can either use npm or Angular's CLI to install Akita dependencies.

Using npm

npm install @datorama/akita --save

Using Angular's CLI

To use the Angular CLI, you will need to have the CLI installed globally. Refer to instructions on the Angular docs for how to set this up.

ng add @datorama/akita

You will also be using Akita's Effects to handle your store's side effects. Let's add that dependency using npm.

npm i @datorama/akita-ng-effects --save

At the time this post was written, the latest Akita store and effects version was 6.2.1 and 1.0.4 respectively, which will be the version we will be using throughout the tutorial.

Architecture

The app's core is an admin dashboard where the logged-in user can add, edit, and delete a menu item. The logged-in user will perform all the operations or a subset of it depending on their permissions. You will use Akita to manage the state updates and user/server-related events.

You will create two Stores for our application:

  • menus: to manage menu related functionalities (CRUD operations)
  • user: to manage user-related functionalities such as authentication using Auth0

You will also split each part of the Store into individual files to make it easier to follow throughout the tutorial.

Let's first start by creating the following folder structure in our core directory (you will be updating each file as we discuss each section):

|- src/app/core
    |- state
        |- menus
            |- menus.actions.ts
            |- menus.effects.ts
            |- menus.query.ts
            |- menus.store.ts
            |- index.ts
        |- user
            |- user.actions.ts
            |- user.effects.ts
            |- user.query.ts
            |- user.store.ts
            |- index.ts

The starter app uses a BehaviorSubject in menu-state.service.ts to manage its state. This tutorial will walk you through migrating the BehaviorSubject based state management to Akita.

Create menus store

Let's start with creating the state object for menus. Open menus.store.ts and add the following code 👇

// src/app/core/state/menus/menus.store.ts

import { Injectable } from "@angular/core";
import { Store, StoreConfig } from "@datorama/akita";
import { MenuItem } from "../../models";

export interface MenusState {
  menus: MenuItem[];
}

export function createInitialState(): MenusState {
  return {
    menus: [],
  };
}

@StoreConfig({ name: "menus" })
@Injectable({ providedIn: "root" })
export class MenusStore extends Store<MenusState> {
  constructor() {
    super(createInitialState());
  }
}

Let's also create a barrel export in our menus folder. Create index.ts and add the following code 👇

// src/app/core/state/menus/index.ts

export * from "./menus.store";

The interface MenusState defines the type of object Menu's state will have. The createInitialState function, on the other hand, returns the initial state of the state object when the store is first initialized.

Create menus action

Next, you will need to define what Actions you need to handle. We will be following the Good Action Hygiene principle when writing our Actions.

On a high level, Good Action Hygiene recommends thinking of Actions as events instead of commands. Instead of addMenuItem as the Action, use addMenuItemFormSubmitted. This pattern also encourages dispatching specific Action instead of reusing the same Action and including the source as part of the Action type. The image below shows the anatomy of an Action's type.

Anatomy of an Action name

Following this pattern makes it easier to debug as you have distinct Actions from each source. This lets you immediately know what event has just taken place and where it came from with a quick look at the devtools.

You can learn more about Good Action hygiene in this blog post by Sameera Perera and Mike Ryan's talk at ng-conf 2018.

Let's start with user-initiated Actions. This would include adding a new menu item, updating an existing menu item, and deleting a current menu item. Open menus.actions.ts and add the following code 👇

// src/app/core/state/menus/menus.actions.ts

import { createAction, props } from "@datorama/akita-ng-effects";
import { BaseMenuItem, MenuItem } from "../../models";

export const addMenuItemFormSubmitted = createAction(
  "[Add Menu Page] Add Menu Item Form Submitted",
  props<{ menuItem: BaseMenuItem }>()
);

export const editMenuItemFormSubmitted = createAction(
  "[Edit Menu Page] Edit Menu Item Form Submitted",
  props<{ menuItem: MenuItem }>()
);

export const deleteMenuItemInitiated = createAction(
  "[Delete Menu Page] Delete Menu Item Initiated",
  props<{ menuId: string }>()
);

You will need to fetch the menus from our API as the app load. Let's add an appLoaded Action that will be dispatched when the app loads. Open menus.actions.ts and add the following code 👇

// src/app/core/state/menus/menus.actions.ts

import { createAction, props } from "@datorama/akita-ng-effects";
import { BaseMenuItem, MenuItem } from "../../models";

// ✨ New 👇
export const appLoaded = createAction("[App] App Loaded");

export const addMenuItemFormSubmitted = createAction(
  "[Add Menu Page] Add Menu Item Form Submitted",
  props<{ menuItem: BaseMenuItem }>()
);

export const editMenuItemFormSubmitted = createAction(
  "[Edit Menu Page] Edit Menu Item Form Submitted",
  props<{ menuItem: MenuItem }>()
);

export const deleteMenuItemInitiated = createAction(
  "[Delete Menu Page] Delete Menu Item Initiated",
  props<{ menuId: string }>()
);

Let's continue to other API-related Actions. Since calling an API could either succeed or fail, let's add success and fail handlers for each API-related Action.

You could do success and error handling in several different ways. What I'm showing you is just one way you could do this.

Open menus.actions.ts and add the following code 👇

// src/app/core/state/menus/menus.actions.ts

import { createAction, props } from "@datorama/akita-ng-effects";
import { BaseMenuItem, MenuItem } from "../../models";

export const appLoaded = createAction("[App] App Loaded");

// ✨ New 👇
export const fetchMenuSuccess = createAction(
  "[Menu API] Fetch Menu Success",
  props<{ menuItems: MenuItem[] }>()
);

// ✨ New 👇
export const fetchMenuFailed = createAction(
  "[Menu API] Fetch Menu Failed",
  props<{ error: any }>()
);

export const addMenuItemFormSubmitted = createAction(
  "[Add Menu Page] Add Menu Item Form Submitted",
  props<{ menuItem: BaseMenuItem }>()
);

// ✨ New 👇
export const addMenuItemSuccess = createAction(
  "[Menu API] Add Menu Item Success"
);

// ✨ New 👇
export const addMenuItemFailed = createAction(
  "[Menu API] Add Menu Item Failed",
  props<{ error: any }>()
);

export const editMenuItemFormSubmitted = createAction(
  "[Edit Menu Page] Edit Menu Item Form Submitted",
  props<{ menuItem: MenuItem }>()
);

// ✨ New 👇
export const editMenuItemSuccess = createAction(
  "[Menu API] Edit Menu Item Success",
  props<{ menuItem: MenuItem }>()
);

// ✨ New 👇
export const editMenuItemFailed = createAction(
  "[Menu API] Edit Menu Item Failed",
  props<{ error: any }>()
);

export const deleteMenuItemInitiated = createAction(
  "[Delete Menu Page] Delete Menu Item Initiated",
  props<{ menuId: string }>()
);

// ✨ New 👇
export const deleteMenuItemSuccess = createAction(
  "[Menu API] Delete Menu Item Success",
  props<{ menuId: string }>()
);

// ✨ New 👇
export const deleteMenuItemFailed = createAction(
  "[Menu API] Delete Menu Item Failed",
  props<{ error: any }>()
);

Let's also add this to the barrel export. Open index.ts and add the following code:

// src/app/core/state/menus/index.ts

export * from "./menus.store";

// ✨ New 👇
export * from "./menus.actions";

Update application to use menus actions

You will now update the current implementation in our application with these Actions for any state-related operations. You do this by injecting Akita's Actions in the component and calling its dispatch function with the Action you want to execute.

Starting with appLoaded, as this is called when the application first loads, let's add this to your AppComponent's ngOnInit. Open app.component.ts and add the following code 👇

// src/app/app.component.ts

import { Component, OnInit } from "@angular/core";

// ✨ New 👇
import { Actions } from "@datorama/akita-ng-effects";
import { appLoaded } from "./core/state/menus";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
  title = "spa-angular-typescript-dashboard";

  // ✨ New 👇
  constructor(private actions: Actions) {}

  ngOnInit(): void {
    // ✨ New 👇
    this.actions.dispatch(appLoaded());
  }
}

Moving on to adding a menu item. Open add-item.component.ts and add the following code 👇

// src/app/features/menu/add-item/add-item.component.ts

import { Component } from "@angular/core";
import { Location } from "@angular/common";
import { BaseMenuItem } from "src/app/core";

// ✨ New 👇
import { Actions } from "@datorama/akita-ng-effects";
import { addMenuItemFormSubmitted } from "src/app/core/state/menus";

const MenuItemPlaceholder: BaseMenuItem = {
  name: "French Fries",
  price: 299,
  tagline: "Crispy goodness",
  description:
    "A plate of light and crispy French fries using Idaho potatoes and peanut oil",
  image:
    "https://as2.ftcdn.net/jpg/02/13/18/09/500_F_213180964_DfqvRIHj0D3t9duYUROXuQ011AgVJIaM.jpg",
  calories: 410,
  category: "sides",
};

@Component({
  selector: "app-add-item",
  templateUrl: "./add-item.component.html",
  styles: [
    `
      :host {
        width: 100%;
        height: 100%;
      }
    `,
  ],
})
export class AddItemComponent {
  menuItem = MenuItemPlaceholder;
  constructor(
    private location: Location,
    // ✨ New 👇
    private actions: Actions
  ) {}

  submit(menu: BaseMenuItem): void {
    // ✨ New 👇
    this.actions.dispatch(addMenuItemFormSubmitted({ menuItem: menu }));
  }

  cancel(): void {
    this.location.back();
  }
}

Deleting a menu item. Open delete-item.component.ts and add the following code 👇

// src/app/features/menu/delete-item/delete-item.component.ts

import { Component } from "@angular/core";
import { Location } from "@angular/common";
import { ActivatedRoute, Router } from "@angular/router";
import { map, switchMap } from "rxjs/operators";
import { MenusStateService } from "src/app/core";

// ✨ New 👇
import { Actions } from "@datorama/akita-ng-effects";
import { deleteMenuItemInitiated } from "src/app/core/state/menus";

@Component({
  selector: "app-delete-item",
  templateUrl: "./delete-item.component.html",
  styleUrls: ["./delete-item.component.scss"],
})
export class DeleteItemComponent {
  menuItemId$ = this.activatedRoute.params.pipe(map((params) => params.id));
  menuItem$ = this.menuItemId$.pipe(
    switchMap((id) => this.menusStateService.selectMenuItem$(id))
  );

  constructor(
    private activatedRoute: ActivatedRoute,
    private location: Location,
    private router: Router,
    private menusStateService: MenusStateService,
    // ✨ New 👇
    private actions: Actions
  ) {}

  deleteMenuItem(id: string): void {
    // ✨ New 👇
    this.actions.dispatch(deleteMenuItemInitiated({ menuId: id }));
  }

  cancel(): void {
    this.back();
  }

  back(): void {
    this.location.back();
  }

  navigateHome(): void {
    this.router.navigate(["/menu"]);
  }
}

Editing an existing menu item. Open edit-item.component.ts and add the following code 👇

// src/app/features/menu/edit-item/edit-item.component.ts

import { Component } from "@angular/core";
import { Location } from "@angular/common";
import { ActivatedRoute } from "@angular/router";
import { map, switchMap, tap } from "rxjs/operators";
import { BaseMenuItem, MenusStateService } from "src/app/core";

// ✨ New 👇
import { Actions } from "@datorama/akita-ng-effects";
import { editMenuItemFormSubmitted } from "src/app/core/state/menus";

@Component({
  selector: "app-edit-item",
  templateUrl: "./edit-item.component.html",
  styles: [
    `
      :host {
        width: 100%;
        height: 100%;
      }
    `,
  ],
})
export class EditItemComponent {
  menuItemId$ = this.activatedRoute.params.pipe(map((params) => params.id));
  menuItem$ = this.menuItemId$.pipe(
    tap((id) => (this.id = id)),
    switchMap((id) => this.menusStateService.selectMenuItem$(id))
  );

  private id: number | undefined;

  constructor(
    private activatedRoute: ActivatedRoute,
    private location: Location,
    private menusStateService: MenusStateService,
    // ✨ New 👇
    private actions: Actions
  ) {}

  cancel(): void {
    this.location.back();
  }

  submit(menu: BaseMenuItem): void {
    if (!this.id) {
      return;
    }
    // ✨ New 👇
    this.actions.dispatch(
      editMenuItemFormSubmitted({
        menuItem: {
          ...menu,
          id: this.id.toString(),
        },
      })
    );
  }
}

Create menus query

Before creating individual queries for slices of the menu state, let's start by creating the boilerplate required to use this feature. Akita uses an Injectable class that extends Akita's Query class. Open menus.query.ts and add the following code 👇

// src/app/core/state/menus/menus.query.ts

import { Injectable } from "@angular/core";
import { Query } from "@datorama/akita";
import { MenusState, MenusStore } from "./menus.store";

@Injectable({ providedIn: "root" })
export class MenusQuery extends Query<MenusState> {
  constructor(protected store: MenusStore) {
    super(store);
  }
}

You can use Akita's select method to select slices of the state. For menus, you only have one entry in our object: menuItems. Let's create an observable to access that using Akita's select method and passing in the store name ("menus"). Open menus.query.ts and update it with the following code 👇

// src/app/core/state/menus/menus.query.ts

import { Injectable } from "@angular/core";
import { Query } from "@datorama/akita";
import { MenusState, MenusStore } from "./menus.store";

@Injectable({ providedIn: "root" })
export class MenusQuery extends Query<MenusState> {
  // ✨ New 👇
  selectMenuItems$ = this.select("menus");

  constructor(protected store: MenusStore) {
    super(store);
  }
}

For pages like Menu Details, Edit Menu and Delete Menu, you'll need to access a specific menu item from the Store. You can do this by creating a function that accepts the menu id as a parameter and searches through our array of menu items from the selectMenuItems$ observable. Open menus.query.ts and add the following code 👇

// src/app/core/state/menus/menus.query.ts

import { Injectable } from "@angular/core";
import { Query } from "@datorama/akita";
import { map } from "rxjs/operators";
import { MenusState, MenusStore } from "./menus.store";

@Injectable({ providedIn: "root" })
export class MenusQuery extends Query<MenusState> {
  selectMenuItems$ = this.select("menus");

  constructor(protected store: MenusStore) {
    super(store);
  }

  // ✨ New 👇
  selectMenuItem(id: string) {
    return this.selectMenuItems$.pipe(
      map((menuItems) => menuItems.find((menuItem) => menuItem.id === id))
    );
  }
}

Let's also add the query to the barrel export. Open index.ts and add the following code 👇

// src/app/core/state/menus/index.ts

export * from "./menus.store";
export * from "./menus.actions";

// ✨ New 👇
export * from "./menus.query";

Update application to use menus queries

Similar to what you did with Actions, let's update the application to get data required by the components using Akita's Queries. You can use Queries by injecting the Query class and calling the function you've defined in the previous section, similar to how you would use a service.

Starting with the main dashboard, open menu-items.component.ts and add the following code 👇

// src/app/features/menu/menu-items/menu-items.component.ts

import { Component } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { RolesService } from "src/app/core";

// ✨ New 👇
import { MenusQuery } from "src/app/core/state/menus";

@Component({
  selector: "app-menu-items",
  templateUrl: "./menu-items.component.html",
  styles: [
    `
      :host {
        width: 100%;
        height: 100%;
      }
    `,
  ],
})
export class MenuItemsComponent {
  // ✨ New 👇
  menuItems$ = this.menuQuery.selectMenuItems$;
  isAdmin$ = this.rolesService.isAdmin$;

  constructor(
    private router: Router,
    private activatedRoute: ActivatedRoute,
    private rolesService: RolesService,
    // ✨ New 👇
    private menuQuery: MenusQuery
  ) {}

  addMenuItem(): void {
    this.router.navigate(["add"], { relativeTo: this.activatedRoute });
  }
}

Moving on to the Menu Details page. Here, we only need a specific menu's data. Instead of using the selectMenuItems, you will use selectMenuItem and pass in the menu id from the route parameters. Open menu-item.component.ts and add the following code 👇

// src/app/features/menu/menu-item/menu-item.component.ts

import { Component } from "@angular/core";
import { Location } from "@angular/common";
import { ActivatedRoute, Router } from "@angular/router";
import { map, switchMap } from "rxjs/operators";
import { RolesService } from "src/app/core";

// ✨ New 👇
import { MenusQuery } from "src/app/core/state/menus";

@Component({
  selector: "app-menu-item",
  templateUrl: "./menu-item.component.html",
  styleUrls: ["./menu-item.component.scss"],
})
export class MenuItemComponent {
  menuItemId$ = this.activatedRoute.params.pipe(map((params) => params.id));

  // ✨ New 👇
  menuItem$ = this.menuItemId$.pipe(
    switchMap((id) => this.menusQuery.selectMenuItem(id))
  );
  isAdmin$ = this.rolesService.isAdmin$;

  constructor(
    private activatedRoute: ActivatedRoute,
    private router: Router,
    private location: Location,
    private rolesService: RolesService,
    // ✨ New 👇
    private menusQuery: MenusQuery
  ) {}

  back(): void {
    this.location.back();
  }

  navigateTo(url: string): void {
    this.router.navigateByUrl(`${this.router.url}/${url}`);
  }
}

Delete Menu Item Component, open delete-item.component.ts and add the following code 👇

// src/app/features/menu/delete-item/delete-item.component.ts

import { Component } from "@angular/core";
import { Location } from "@angular/common";
import { ActivatedRoute, Router } from "@angular/router";
import { map, switchMap } from "rxjs/operators";

import { Actions } from "@datorama/akita-ng-effects";
// ✨ New 👇
import { deleteMenuItemInitiated, MenusQuery } from "src/app/core/state/menus";

@Component({
  selector: "app-delete-item",
  templateUrl: "./delete-item.component.html",
  styleUrls: ["./delete-item.component.scss"],
})
export class DeleteItemComponent {
  menuItemId$ = this.activatedRoute.params.pipe(map((params) => params.id));

  // ✨ New 👇
  menuItem$ = this.menuItemId$.pipe(
    switchMap((id) => this.menusQuery.selectMenuItem(id))
  );

  constructor(
    private activatedRoute: ActivatedRoute,
    private location: Location,
    private router: Router,
    private actions: Actions,
    // ✨ New 👇
    private menusQuery: MenusQuery
  ) {}

  deleteMenuItem(id: string): void {
    this.actions.dispatch(deleteMenuItemInitiated({ menuId: id }));
  }

  cancel(): void {
    this.back();
  }

  back(): void {
    this.location.back();
  }

  navigateHome(): void {
    this.router.navigate(["/menu"]);
  }
}

And finally, Edit Menu Item. Open edit-item.component.ts and add the following code 👇

// src/app/features/menu/edit-item/edit-item.component.ts

import { Component } from "@angular/core";
import { Location } from "@angular/common";
import { ActivatedRoute } from "@angular/router";
import { map, switchMap, tap } from "rxjs/operators";
import { BaseMenuItem, MenuItem } from "src/app/core";

import { Actions } from "@datorama/akita-ng-effects";
// ✨ New 👇
import {
  editMenuItemFormSubmitted,
  MenusQuery,
} from "src/app/core/state/menus";

@Component({
  selector: "app-edit-item",
  templateUrl: "./edit-item.component.html",
  styles: [
    `
      :host {
        width: 100%;
        height: 100%;
      }
    `,
  ],
})
export class EditItemComponent {
  menuItemId$ = this.activatedRoute.params.pipe(map((params) => params.id));

  // ✨ New 👇
  menuItem$ = this.menuItemId$.pipe(
    tap((id) => (this.id = id)),
    switchMap((id) => this.menusQuery.selectMenuItem(id))
  );

  private id: number | undefined;

  constructor(
    private activatedRoute: ActivatedRoute,
    private location: Location,
    private actions: Actions,
    // ✨ New 👇
    private menusQuery: MenusQuery
  ) {}

  cancel(): void {
    this.location.back();
  }

  submit(menu: BaseMenuItem): void {
    if (!this.id) {
      return;
    }
    this.actions.dispatch(
      editMenuItemFormSubmitted({
        menuItem: {
          ...menu,
          id: this.id.toString(),
        },
      })
    );
  }
}

Create menus effects

As explained at the beginning of the post, Effects are where side effects of the Action are handled. For Menus, this will mostly be making an HTTP request to get data from the server or perform other CRUD operations. In addition to handling side effects, We will also be using the Effects to update our state object. Let's start with creating an empty class with the Injectable decorator. Create menus.effects.ts in the core/state/menus/ directory and add the following code 👇

// src/app/core/state/menus/menus.effects.ts

import { Injectable } from "@angular/core";

@Injectable()
export class MenusEffects {}

Next, let's start with a simple effect that handles fetching the menu items from the API and updating the store with new data. Since this logic needs to be executed when the app loads and a new menu item is added successfully, you will pass in 2 actions - MenusActions.appLoaded and MenusActions.addMenuItemSuccess. You will then switchMap to the apiService to get the menu items. If it's successful, dispatch the fecthMenuSuccess action with the menu items returned from the API which will update the store with this new data. If unsuccessful, it will jump to the catchError block, which dispatches the fetchMenuFailed action with the error thrown. MenusActions.fetchMenuSuccess will then update the state object with the new menu items returned by the API call.

Diagram of how the fetch menu effect works

Tip: add your catchError to the inner observable in your switchMap instead of on the main observable to prevent the action stream from being closed when an error is thrown

Open menus.effects.ts and add the following code 👇

// src/app/core/state/menus/menus.effects.ts

import { Injectable } from "@angular/core";

// ✨ New 👇
import { of } from "rxjs";
import { map, tap, switchMap, catchError } from "rxjs/operators";
import { Actions, createEffect, ofType } from "@datorama/akita-ng-effects";
import { ApiService } from "../../services";
import * as MenusActions from "./menus.actions";
import { MenusStore } from "./menus.store";

@Injectable()
export class MenusEffects {
  // ✨ New 👇
  constructor(
    private actions$: Actions,
    private menusStore: MenusStore,
    private apiService: ApiService
  ) {}

  // ✨ New 👇
  fetchMenu$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.appLoaded, MenusActions.addMenuItemSuccess),
        switchMap(() =>
          this.apiService.getItems().pipe(
            map((menuItems) =>
              MenusActions.fetchMenuSuccess({ menuItems: menuItems })
            ),
            catchError((error) =>
              of(MenusActions.fetchMenuFailed({ error: error }))
            )
          )
        )
      ),
    { dispatch: true }
  );

  // ✨ New 👇
  fetchMenuSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.fetchMenuSuccess),
        tap((action) => {
          this.menusStore.update((state) => ({
            ...state,
            menus: action.menuItems,
          }));
        })
      ),
    { dispatch: false }
  );
}

Following the same logic, let's create effects for addMenu, editMenu, and deleteMenu along with their success handlers to update the state object. Open menus.effects.ts and update it with the following code 👇

// src/app/core/state/menus/menus.effects.ts

import { Injectable } from "@angular/core";
// ✨ New 👇
import { Location } from "@angular/common";
// ✨ New 👇
import { Router } from "@angular/router";
import { of } from "rxjs";
import { map, tap, switchMap, catchError } from "rxjs/operators";
import { Actions, createEffect, ofType } from "@datorama/akita-ng-effects";
import { ApiService } from "../../services";
import * as MenusActions from "./menus.actions";
import { MenusStore } from "./menus.store";

@Injectable()
export class MenusEffects {
  constructor(
    // ✨ New 👇
    private router: Router,
    // ✨ New 👇
    private location: Location,
    private actions$: Actions,
    private menusStore: MenusStore,
    private apiService: ApiService
  ) {}

  fetchMenu$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.appLoaded, MenusActions.addMenuItemSuccess),
        switchMap(() =>
          this.apiService.getItems().pipe(
            map((menuItems) =>
              MenusActions.fetchMenuSuccess({ menuItems: menuItems })
            ),
            catchError((error) =>
              of(MenusActions.fetchMenuFailed({ error: error }))
            )
          )
        )
      ),
    { dispatch: true }
  );

  fetchMenuSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.fetchMenuSuccess),
        tap((action) => {
          this.menusStore.update((state) => ({
            ...state,
            menus: action.menuItems,
          }));
        })
      ),
    { dispatch: false }
  );

  // ✨ New 👇
  addMenu$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.addMenuItemFormSubmitted),
        switchMap((action) =>
          this.apiService.addItem(action.menuItem).pipe(
            tap(() => this.router.navigate(["/menu"])),
            map(() => MenusActions.addMenuItemSuccess()),
            catchError((error) =>
              of(MenusActions.addMenuItemFailed({ error: error }))
            )
          )
        )
      ),
    { dispatch: true }
  );

  // ✨ New 👇
  editMenu$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.editMenuItemFormSubmitted),
        switchMap((action) =>
          this.apiService.updateItem(action.menuItem).pipe(
            tap(() => this.location.back()),
            map(() =>
              MenusActions.editMenuItemSuccess({ menuItem: action.menuItem })
            ),
            catchError((error) =>
              of(MenusActions.editMenuItemFailed({ error: error }))
            )
          )
        )
      ),
    { dispatch: true }
  );

  // ✨ New 👇
  editMenuSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.editMenuItemSuccess),
        tap((action) => {
          const menuItem = action.menuItem;
          const menuItems = this.menusStore.getValue().menus;
          const menuItemIndex = menuItems.findIndex(
            (item) => item.id === menuItem.id
          );
          const updatedMenuItems = [...menuItems];
          updatedMenuItems[menuItemIndex] = menuItem;
          this.menusStore.update((state) => ({
            ...state,
            menus: updatedMenuItems,
          }));
        })
      ),
    { dispatch: false }
  );

  // ✨ New 👇
  deleteMenu$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.deleteMenuItemInitiated),
        switchMap((action) =>
          this.apiService.deleteItem(action.menuId).pipe(
            tap(() => this.router.navigate(["/menu"])),
            map(() =>
              MenusActions.deleteMenuItemSuccess({ menuId: action.menuId })
            ),
            catchError((error) =>
              of(MenusActions.deleteMenuItemFailed({ error: error }))
            )
          )
        )
      ),
    { dispatch: true }
  );

  // ✨ New 👇
  deleteMenuSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(MenusActions.deleteMenuItemSuccess),
        tap((action) => {
          const menuId = action.menuId;
          const menuItems = this.menusStore.getValue().menus;
          const menuItemIndex = menuItems.findIndex(
            (item) => item.id === menuId
          );
          const updatedMenuItems = [...menuItems];
          updatedMenuItems.splice(menuItemIndex, 1);
          this.menusStore.update((state) => ({
            ...state,
            menus: updatedMenuItems,
          }));
        })
      ),
    { dispatch: false }
  );
}

Tip: You can add the following effect if you want to listen to every action that gets dispatched for debugging purposes (more on debugging in the devtools section) 👇

init$ = createEffect(
  () => this.actions$.pipe(tap((action) => console.log(action))),
  { dispatch: false }
);

Add menus.effects to the menu directory's barrel export. Open index.ts and add the following code 👇

// src/app/core/state/menus/index.ts

export * from "./menus.store";
export * from "./menus.actions";
export * from "./menus.query";

// ✨ New 👇
export * from "./menus.effects";

Configure effects module

You will then need to initialize Akita's EffectsModule passing in all your feature Effects (in our case, just the MenusEffects for now). Open app.module.ts and add the following code 👇

// src/app/app.module.ts

import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { HttpClientModule, HTTP_INTERCEPTORS } from "@angular/common/http";
import { AuthHttpInterceptor, AuthModule } from "@auth0/auth0-angular";
import { AkitaNgDevtools } from "@datorama/akita-ngdevtools";

// ✨ New 👇
import { AkitaNgEffectsModule } from "@datorama/akita-ng-effects";

import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { NavBarModule } from "./shared";
import { environment } from "src/environments/environment";

// ✨ New 👇
import { MenusEffects } from "./core/state/menus";

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    AuthModule.forRoot({
      ...environment.auth,
      httpInterceptor: {
        allowedList: [
          `${environment.serverUrl}/api/menu/items`,
          `${environment.serverUrl}/api/menu/items/*`,
        ],
      },
    }),
    AppRoutingModule,
    NavBarModule,
    environment.production
      ? []
      : AkitaNgDevtools.forRoot({
          maxAge: 25,
        }),

    // ✨ New 👇
    AkitaNgEffectsModule.forRoot([MenusEffects]),
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthHttpInterceptor,
      multi: true,
    },
  ],
})
export class AppModule {}

Checkpoint: Executing any CRUD operations such as adding a new menu item and editing or deleting an existing menu item should make an API call to update the server's database and also update the app's state. Only authenticated users with a menu-admin role can create, update, and delete menu items. The menu-admin role bundles the necessary permissions to execute these write operations. Read more on how to configure role-based access control (RBAC) and how to create an admin user for this application in this blog post

Conclusion

We've covered how Akita works, its installation, and how to use it to manage our application's state. In the second half of this tutorial: State Management in Angular with Akita - Part 2, we'll learn how to use Auth0 with Akita to manage user-related states.

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon