Building an Angular application from scratch with Nx and NgRx

Brandon Roberts - Feb 24 '21 - - Dev Community

Cover image by Jyoti Singh on Unsplash

Structure and patterns go a long way when building scalable applications. Nx is a set of build tools that help you manage and maintain a monorepo, structure your codebase to contain many reusable libraries, and scale across many different applications. NgRx is a framework of libraries to help you structure and build features in your application using a consistent pattern for state management. Using these two things together helps accelerate your development, but there are some steps you should follow to maximize the benefits they provide. This post walks through building a Seinfeld quotes generator in Angular using an Nx workspace and NgRx.

Creating the workspace

Start with creating the Nx workspace with an Angular application.

npx create-nx-workspace@latest seinfeld --appName=quotes --preset=angular --style=css --routing
Enter fullscreen mode Exit fullscreen mode
  • Choose ESLint for linting files in your workspace.
  • Optionally choose to connect your workspace to Nx Cloud.

This command generates a new workspace, and scaffolds out a new Angular application with modern tooling, including Jest for unit tests, and Cypress for E2E tests.

Setting up NgRx Store, Effects, and Devtools

To get started with NgRx, the best place is to always start with the schematics, as they handle all the initial setup for the NgRx packages.

nx g ngrx app --root --no-interactive --project quotes --module apps/quotes/src/app/app.module.ts
Enter fullscreen mode Exit fullscreen mode

This command does two things:

  • Installs all the NgRx packages.
  • Takes care of the initial setup for NgRx Store, Effects, and Store Devtools in the application AppModule.

Managing a feature with NgRx does involve using actions, reducers, selectors, and effects. Each one of these things has a specific purpose in state management.

  • The Store is a singleton service that contains all of the shared feature states, managed by reducers. There is only one global Store with multiple feature states contained within that store.
  • Actions represent unique events.
  • Reducers handle state changes based on actions.
  • Selectors provide real-time access to read state.
  • Effects are listeners of observable streams that isolate side effects from components and handle tasks such as the business logic of data fetching.

Read more about these concepts in the NgRx docs.

Serve up the application:

nx serve
Enter fullscreen mode Exit fullscreen mode

The initial application component is displayed. The completed feature lists a set of Seinfeld quotes, including author, season, and episode.

Next up is generating libraries.

Generating libraries

Generating libraries in an Nx workspace is done by using the generate command along with the Angular CLI. For the quotes feature, generate two libraries residing under a quotes folder. It's good to keep your folder structure relatively flat, but at least one level deep is good for flexibility.

Generate a library for setting up data access for quotes:

nx g lib quotes/data-access
Enter fullscreen mode Exit fullscreen mode

A "data access" library is where functionality for fetching and storing data for a feature exists. So any services necessary to manage the quotes are placed in this library. Over the course of building applications, there will be data access libraries across multiple features.

Generate a library for the quotes page feature:

nx g lib quotes/page
Enter fullscreen mode Exit fullscreen mode

Generate a service for fetching Seinfeld quotes:

nx g service quotes --project quotes-data-access
Enter fullscreen mode Exit fullscreen mode

This generates the service inside of the data access library.

Setting up the feature

For the quotes feature page, a component is used to list the quotes.

Generate a component for the quotes page feature.

nx g component quotes-page --project quotes-page
Enter fullscreen mode Exit fullscreen mode

With the feature page created, define a route in the quotes-page.module.ts with an empty path route. This module is lazy-loaded from the AppModule routes.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';

import { QuotesPageComponent } from './quotes-page/quotes-page.component';

@NgModule({
  imports: [
    CommonModule,
    RouterModule.forChild([
      { path: '', component: QuotesPageComponent } 
    ])
  ],
  declarations: [QuotesPageComponent],
})
export class QuotesPageModule {}
Enter fullscreen mode Exit fullscreen mode

Update the AppModule in the quotes app to load this feature using the Angular Router. Also add the HttpClientModule to the AppModule imports array.

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';

import { AppComponent } from './app.component';
import { environment } from '../environments/environment';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    HttpClientModule,
    RouterModule.forRoot([
      { path: '', pathMatch: 'full', redirectTo: 'quotes' },
      { path: 'quotes', loadChildren: () => import('@seinfeld/quotes/page').then(m => m.QuotesPageModule) }
    ]),
    StoreModule.forRoot({}),
    EffectsModule.forRoot([]),
    !environment.production ? StoreDevtoolsModule.instrument() : [],
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Update the app.component.html, and replace the contents of the main tag with the <router-outlet></router-outlet> directive.

<main>

  <router-outlet></router-outlet>

</main>
Enter fullscreen mode Exit fullscreen mode

Reloading the app in the browser displays quotes page works!.

Setting up the NgRx feature state

As I mentioned before, schematics handle a lot of the wiring up of state for you. Generating state for the quotes feature can be done quickly using the ngrx schematic.

nx g ngrx quotes --project quotes-data-access --no-interactive --module libs/quotes/data-access/src/lib/quotes-data-access.module.ts --barrels
Enter fullscreen mode Exit fullscreen mode

This schematic generates:

  • A QuotesEntity model representing a single quote.
  • Quotes Page/API actions
  • A reducer to handle the collection of quotes
  • The selectors to "query" or read from the quote state
  • Side effects to isolate interactions with external APIs
  • Barrel exports in the index.ts file for consumption in other features.

The feature state and side effects are also added to the QuotesDataAccessModule

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import * as fromQuotes from './+state/quotes.reducer';
import { QuotesEffects } from './+state/quotes.effects';

@NgModule({
  imports: [
    CommonModule,
    StoreModule.forFeature(fromQuotes.QUOTES_FEATURE_KEY, fromQuotes.reducer),
    EffectsModule.forFeature([QuotesEffects]),
  ],
})
export class QuotesDataAccessModule {}
Enter fullscreen mode Exit fullscreen mode

The entire feature state is set up and ready to tweak for added functionality.

Seinfeld - Kramer Mind Blown

When this feature is loaded, the quotes state is added to the global state, and the QuotesEffects start listening for dispatched actions to perform tasks.

NgRx Tip: To generate an empty feature state, use the feature schematic from the @ngrx/schematics package.

nx g @ngrx/schematics:feature quote
Enter fullscreen mode Exit fullscreen mode

Follow the prompts to generate and set up the NgRx feature state.

Setting up Services and Side Effects

The state for the feature is set up already, but the quotes service and quotes effects still need to be connected. Update the quotes service to fetch quotes from the Seinfeld API.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators';

import { QuotesEntity } from './+state/quotes.models';

@Injectable({
  providedIn: 'root'
})
export class QuotesService {
  private API_URL = 'https://seinfeld-quotes.herokuapp.com';

  constructor(private http: HttpClient) { }

  getAll() {
    return this.http.get<{ quotes: QuotesEntity[] }>(`${this.API_URL}/quotes`)
      .pipe(
        map(response => {
          return response.quotes.map((quote, index) => {
            return {
              id: index,
              ...quote
            };
          });
        })
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

The Seinfeld API being used returns all quotes, but without a unique ID, so the snippet above maps each quote and uses the array index as the id. With the quotes service defined, connect it to the Effects.

import { Injectable } from '@angular/core';
import { createEffect, Actions, ofType } from '@ngrx/effects';
import { fetch } from '@nrwl/angular';
import { map } from 'rxjs/operators';

import * as QuotesActions from './quotes.actions';
import { QuotesService } from '../quotes.service';

@Injectable()
export class QuotesEffects {
  init$ = createEffect(() =>
    this.actions$.pipe(
      ofType(QuotesActions.init),
      fetch({
        run: (action) => {
          return this.quotesService.getAll()
            .pipe(
              map(quotes => QuotesActions.loadQuotesSuccess({ quotes }))
            );
        },

        onError: (action, error) => {
          console.error('Error', error);
          return QuotesActions.loadQuotesFailure({ error });
        },
      })
    )
  );

  constructor(private actions$: Actions, private quotesService: QuotesService) {}
}
Enter fullscreen mode Exit fullscreen mode

Effects listen to all actions that are dispatched from the Store. After the init action is dispatched, the quotes are fetched, and the response is mapped into the loadQuotesSucess action. This action is used in the quotes reducer to load the quotes into the quotes state.

const quotesReducer = createReducer(
  initialState,
  on(QuotesActions.init, (state) => ({ ...state, loaded: false, error: null })),
  on(QuotesActions.loadQuotesSuccess, (state, { quotes }) =>
    quotesAdapter.setAll(quotes, { ...state, loaded: true })
  ),
  on(QuotesActions.loadQuotesFailure, (state, { error }) => ({
    ...state,
    error,
  }))
);
Enter fullscreen mode Exit fullscreen mode

The generated reducer also listens to all dispatched actions and updates the state if necessary. The collection of quotes is managed with an adapter created using NgRx Entity. This reduces the amount of code you have to write to manage these collections across your application.

Registering the feature state

The feature state is ready to use in the quotes page library. Import the QuotesDataAccessModule and add it to the imports array of the QuotesPageModule. This registers the NgRx quotes state in the Store when the feature is loaded.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { QuotesDataAccessModule } from '@seinfeld/quotes/data-access';

import { QuotesPageComponent } from './quotes-page/quotes-page.component';

@NgModule({
  imports: [
    CommonModule,
    RouterModule.forChild([
      { path: '', component: QuotesPageComponent } 
    ]),
    QuotesDataAccessModule
  ],
  declarations: [QuotesPageComponent],
})
export class QuotesPageModule {}
Enter fullscreen mode Exit fullscreen mode

Dispatching actions

In the QuotesPageComponent, inject the Store service from the @ngrx/store package, and use the ngOnInit lifecycle method and the Store.dispatch method to dispatch the QuotesActions.init() action.

import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { QuotesActions } from '@seinfeld/quotes/data-access';

@Component({
  selector: 'seinfeld-quotes-page',
  templateUrl: './quotes-page.component.html',
  styleUrls: ['./quotes-page.component.css']
})
export class QuotesPageComponent implements OnInit {

  constructor(private store: Store) { }

  ngOnInit(): void {
    this.store.dispatch(QuotesActions.init());
  }
}
Enter fullscreen mode Exit fullscreen mode

The QuotesEffects are listening for this action and will fetch the quotes, and load them into the quotes feature state.

Querying Data with Selectors

After the API request finishes successfully, the quotes state contains all the requested quotes. Only a subset of that data is needed in this example. Using selectors is the recommended way to transform pieces of state from the store.

In the quotes.selectors.ts, create a new selector named getRandomQuotes that takes 10 quotes from the complete list of quotes. This selector builds on top of the getAllQuotes selector generated with the feature.

export const getRandomQuotes = createSelector(getAllQuotes, (quotes) => {
  const start = Math.floor(Math.random() * quotes.length);

  return quotes.slice(start, start + 10).map((quote, index) => {
    return {
      id: index,
      ...quote,
    };
  });
});
Enter fullscreen mode Exit fullscreen mode

This selector takes a random starting point from the collection of quotes and creates a new array of 10 quotes to return.

To list the quotes, define a quotes$ property in the QuotesPageComponent that uses the Store.select method, and the QuotesSelectors.getRandomQuotes selector.

import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { QuotesActions, QuotesSelectors } from '@seinfeld/quotes/data-access';

@Component({
  selector: 'seinfeld-quotes-page',
  templateUrl: './quotes-page.component.html',
  styleUrls: ['./quotes-page.component.css']
})
export class QuotesPageComponent implements OnInit {
  quotes$ = this.store.select(QuotesSelectors.getRandomQuotes);

  constructor(private store: Store) { }

  ngOnInit(): void {
    this.store.dispatch(QuotesActions.init());
  }
}
Enter fullscreen mode Exit fullscreen mode

Update the QuotesPageComponent template to display the list of quotes.

<h2>Quotes</h2>

<div *ngFor="let quote of quotes$ | async">
  <div>
    {{ quote.quote }}
  </div>
  <div>
    Author: {{ quote.author }}
  </div>
  <div>
    Season {{ quote.season }} Episode {{ quote.episode }}
  </div>
  <br/>
</div>
Enter fullscreen mode Exit fullscreen mode

Reloading the application displays a list of Seinfeld quotes, include author, season, and episode.

And that's it!

Seinfeld - Jerry Won The Race

What did we cover?

  • Initial setup of the Nx workspace and application
  • Initial setup of NgRx for the application
  • Generating feature libraries
  • Quickly scaffolding NgRx feature state
  • Connecting NgRx feature state to the feature page

You can see the complete example here on GitHub

In future posts, I'll cover dealing with Good Action Hygiene and local state management with NgRx ComponentStore within an Nx workspace.

Credits

Like this post? Support my OSS work and content by sponsoring me on GitHub!

You can also follow me on Twitter, Twitch, and GitHub!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .