Despite the numerous advances in building web interfaces in the last few years, the control of the DOM and the application User Interface (UI) is still highly dependent on jQuery, a 10-year-old library. Although this is not necessarily a bad thing, jQuery was initially built for different purposes than it's used for nowadays. As a result, modern uses have started to cause issues. Front-end applications have become increasingly complex, with all kinds of unrelated components, encapsulated views and other elements trying to interact together.
In this guide, we'll explore using Redux to mitigate the current challenges in controlling the UI of an Angular 2 application. You will learn how to represent some of the logic for controlling the UI layout in reducer functions and see an example use case.
Since Redux came out, state management for front-end apbplications went through a revolution. My team and I have field-tested Redux with Angular 2 and the resulting productivity boost has been tremendous.
Redux has not only allowed us to ship faster, but it increased the overall maintainability of our codebase by encapsulating most of the crucial logic in a single place and providing an easy to test architecture.
We liked Redux so much that we wanted to control everything with it. One of our most recent projects was building a very UI-intensive application and we decided to experiment by giving the reducers in the applicaiton a little bit more resposibility than just controlling data.
We found out that "reduxifying" the UI leads to numerous benefits and makes controlling the flexibility . It made previously difficult use cases a breeze to implement.
The examples below will be done with ng-bootstrap since it's one of the most popular libraries with Angular 2 components for Bootstrap 4. You can also implement these examples with other component libraries, such as Material Design, by following the same design principles and making small adjustments to the code so that it works with the API of the corresponding library.
Below are the packages you need to install in order to start working:
Redux
Bootstrap
To ensure a smooth setup, we will use Angular CLI to initialize the application architecture. Make sure you have it installed globally before you proceed.
In your terminal, write the following commands to initialize your Angular 2 app:
1$ ng new redux-layout-tutorial-app
2$ cd new redux-layout-tutorial-app
1$ yarn add [email protected]
To add Bootstrap's assets to your project, open angular-cli.json
in your app root and add the following:
1apps: [
2 {
3 //..
4 "styles": [
5 "../node_modules/bootstrap/dist/css/bootstrap.css"
6 ],
7 //...
8 "environments": {
9 //...
10 "scripts": [
11 "../node_modules/jquery/dist/jquery.js",
12 "../node_modules/tether/dist/js/tether.js",
13 "../node_modules/bootstrap/dist/js/bootstrap.js"
14 ]
15 }
This will let your angular-cli
locate the JavaScript and CSS files of your Bootstrap installation and add them in the build of the project.
Next, add ng-bootstrap
to your dependencies:
1$ yarn add @ng-bootstrap/ng-bootstrap
And include the NgbModule
in your app's root module (located in app.module.ts
):
1import {NgbModule} from "@ng-bootstrap/ng-bootstrap";
2
3@NgModule({
4 //..
5 imports: [
6 NgbModule.forRoot()
7 ],
8 //..
9})
Next, we are going to make a bare minimum implementation of a Redux architecture that will serve as a foundaton of all the use cases that will be later implemented in this guide.
Start off by adding the core dependencies for the Redux application store:
1$ yarn add @ngrx/core
2$ yarn add @ngrx/store
For asynchronous events such as pagination and loading bars, in the layout of the application, there needs to be a middleware:
1$ yarn add @ngrx/effects
To make selection of the state fast an efficient, add reselect
. We are going to use reselect
's createSelector
function to create efficient selectors that are memoized and only recompute when arguments change.
1$ yarn add reselect
To make development more convenient and easier to debug, add a store logger, which will log to the console every action and the new state of the state.
1$ yarn add ngrx-store-logger
To structure the application's files properly, all the redux-related files will stay in src/app/common
directory.
1$ mkdir src/app/common
Create common/layout
directory which is going to contain all actions, effects, and the reducer of the layout sate.
1$ mkdir src/app/common/layout
2$ cd src/app/common/layout
In the directory create three files for the layout state:
1$ touch layout.actions.ts
layout.actions.ts
The layout actions will be dispatched every time when an user action is made (closing and opening sidebar, opening a modal and so on) or when certain events happen (window resizing).
1import { Action } from "@ngrx/store";
2
3/*
4 Layout actions are defined here
5 */
6
7export const LayoutActionTypes = {};
8
9/*
10 The action classes will be added here once they are defined
11*/
12export type LayoutActions = null;
layout.reducer.ts
1$ touch layout.reducer.ts
The reducer of the layout will handle all changes of the application layout and create a new state every time the UI has to change.
1import * as layout from "./layout.actions";
2
3export interface State {
4 /*
5 The description of the different parts of the layout go here
6 */
7}
8
9const initialState: State = {
10 /*
11 The initial values of the layout state will be initialized here
12 */
13};
14
15/*
16 The reducer of the layout state. Each time an action for the layout is dispatched,
17 it will create a new state for the layout.
18 */
19export function reducer(
20 state = initialState,
21 action: layout.LayoutActions
22): State {
23 switch (action.type) {
24 default:
25 return state;
26 }
27}
With the UI state ready, the last step is to add the meta reducer, which will eventually be bootstrapped with the StoreModule
provided by @ngrx/store
. If you are not very familiar with Redux and the role of the meta reducer, read here:
1$ touch src/app/common/index.ts
1/*
2 Import createSelector from reselect to make selection of different parts of the state fast efficient
3 */
4import { createSelector } from "reselect";
5/*
6 Import the store logger to log all the actions to the console
7 */
8import { storeLogger } from "ngrx-store-logger";
9
10/*
11 Import the layout state
12 */
13
14import * as fromLayout from "./layout/layout.reducer";
15import { compose } from "@ngrx/core";
16import { combineReducers } from "@ngrx/store";
17
18export interface AppState {
19 reducer: {
20 layout: fromLayout.State;
21 };
22}
23export const reducers = {
24 layout: fromLayout.reducer
25};
26
27const developmentReducer: Function = compose(storeLogger(), combineReducers)(
28 reducers
29);
30
31export function metaReducer(state: any, action: any) {
32 return developmentReducer(state, action);
33}
34
35/**
36 * Layout selectors
37 */
38
39export const getLayoutState = (state: AppState) => state.reducer.layout;
Finally, add the metaReducer
to the StoreModule
in the imports
array of the root module:
1import { StoreModule } from "@ngrx/store";
2import { metaReducer } from "./common/index";
3//...
4
5@NgModule({
6 //...
7 imports: [
8 //Provide the application reducer to the store.
9 StoreModule.forRoot({ reducer: metaReducer })
10 ]
11 //...
12})
13export class AppModule {}
If you are fimilar with Redux, you would know that there are two types of components - presentational components and container components.
When building the UI state, it's best to keep the logic inside directives in order to keep the logic DRY. You don't have to write the same logic for a sidebar in every container component in your application.
Another possibility is to keep the logic in the container and only in exceptional cases there's a need to put the logic inside a component that represents a UI element.
In this guide, the container component will be the AppComponent
. In order to make the state of the application accessible in it and be able to dispatch actions, you have to import layout.actions
and the root state:
1import { Component } from "@angular/core";
2import { Store } from "@ngrx/store";
3import { Observable } from "rxjs";
4/**
5 * Import the root state in order to select parts of it.
6 */
7import * as fromRoot from "./common/index";
8/*
9 * Import the layout actions to make dispatching from the component possible.
10 */
11import * as layout from "./common/layout/layout.actions";
12
13@Component({
14 selector: "app-root",
15 templateUrl: "./app.component.html",
16 styleUrls: ["./app.component.css"]
17})
18export class AppComponent {
19 constructor(private store: Store<fromRoot.AppState>) {}
20}
The easiest way to implement a modal in the state is to keep its name as an identifier. Since only one modal can be opened at a time in the UI view (unless you're trying to do some kind of black magic), every modal can be referenced by a modalName
.
Let's start with the actions. An user can open and close a modal, so let's add actions for that:
layout.actions.ts
1export const LayoutActionTypes = {
2 OPEN_MODAL: "[Layout] Open modal",
3 CLOSE_MODAL: "[Layout] Close modal"
4};
5
6/*
7 Modal actions
8 */
9export class OpenModalAction implements Action {
10 type = LayoutActionTypes.OPEN_MODAL;
11 constructor(public payload: string) {}
12}
13
14export class CloseModalAction implements Action {
15 type = LayoutActionTypes.CLOSE_MODAL;
16 constructor(public payload: string) {}
17}
18
19export type LayoutActions = CloseModalAction | OpenModalAction;
Let's go ahead and implement how modal actions will be handled in the layout reducer: layout.reducer.ts
1import * as layout from "./layout.actions";
2
3export interface State {
4 openedModalName: string;
5}
6
7const initialState: State = {
8 openedModalName: null
9};
10
11export function reducer(
12 state = initialState,
13 action: layout.LayoutActions
14): State {
15 switch (action.type) {
16 /*
17 Modal cases
18 */
19 case layout.LayoutActionTypes.OPEN_MODAL: {
20 const name = action.payload;
21 return Object.assign({}, state, {
22 openedModalName: name
23 });
24 }
25
26 case layout.LayoutActionTypes.CLOSE_MODAL: {
27 return Object.assign({}, state, {
28 openedModalName: null
29 });
30 }
31 default:
32 return state;
33 }
34}
35
36export const getOpenedModalName = (state: State) => state.openedModalName;
The currently modal's name will be stored in the openedModalName
which will be set and unset according to the dispatched action. A selector getOpenedModalName
is needed to easily access the openedModalName
property within the state.
In index.ts
, add a selector to access the openedModalName
property from the application state:
index.ts
1export const getLayoutState = (state: AppState) => state.layout;
2
3//...
4export const getLayoutOpenedModalName = createSelector(
5 getLayoutState,
6 fromLayout.getOpenedModalName
7);
To see how it works, let's create a sample modal:
1$ ng g component template-modal
template-modal.component.ts
1import {
2 Component,
3 ChangeDetectionStrategy,
4 Output,
5 ViewChild,
6 EventEmitter,
7 Input,
8 ElementRef
9} from "@angular/core";
10import { NgbModal, NgbModalRef } from "@ng-bootstrap/ng-bootstrap";
11
12@Component({
13 selector: "template-modal",
14 templateUrl: "template-modal.component.html"
15})
16export class TemplateModalComponent {
17 private modalName: string = "templateFormModal";
18 private modalRef: NgbModalRef;
19
20 @ViewChild("content") _templateModal: ElementRef;
21
22 @Input()
23 set modalState(_modalState: any) {
24 if (_modalState == this.modalName) {
25 this.openModal();
26 } else if (this.modalRef) {
27 this.closeModal();
28 }
29 }
30
31 @Output() onCloseModal = new EventEmitter<any>();
32
33 constructor(private modalService: NgbModal) {}
34
35 openModal() {
36 this.modalRef = this.modalService.open(this._templateModal, {
37 backdrop: "static",
38 keyboard: false,
39 size: "sm"
40 });
41 }
42
43 closeModal() {
44 this.modalRef.close();
45 }
46}
The name of the currently-selected modal is coming from the container and is obtained through the modalState
input of the component. If the name matches with the modalName
(templateFormModal), then the modal is opened through the modalService
.
Conversely, onCloseModal
is used to emit to the container that te user has clicked to close the modal.
template-modal.component.html
1<template #content="" let-c="close" let-d="dismiss">
2 <div class="modal-header">
3 <div class="row">
4 <div class="col-sm-10">
5 Template Modal
6 </div>
7 <div class="col-sm-2">
8 <button class="close" type="button" aria-label="Close" (click)="d('Cross click'); onCloseModal.emit()"><span aria-hidden="true">×</span></button>
9 </div>
10 </div>
11 </div>
12 <div class="modal-body">
13 Modal with Redux using a template. You can put anything here
14 </div>
15 <div class="modal-footer">
16 <div class="btn-group">
17 <button class="btn btn-warning" type="button" (click)="d('Cross click'); onCloseModal.emit()">Close</button>
18 </div>
19 </div>
20</template>
Every time the user attempts to close the modal, onCloseModal
directly emits from the template to the container. In case you have issues understanding how the modal works, you can check the standard implementation of a ng-bootstrap modal without Redux.
In the container there need to be handlers for getting the openedModalName
from the state and for dispatching an action for closing a modal:
app.component.ts
1export class AppComponent {
2 public openedModalName$: Observable<any>;
3
4 constructor(private store: Store<fromRoot.AppState>) {
5 // Use the selector to directly get the opened modal name from the state
6 this.openedModalName$ = store.select(fromRoot.getLayoutOpenedModalName);
7 }
8
9 //Dispatch an action to open a modal
10 handleOpenModal(modalName: string) {
11 this.store.dispatch(new layout.OpenModalAction(modalName));
12 }
13
14 handleCloseModal() {
15 this.store.dispatch(new layout.CloseModalAction());
16 }
17}
As you can see, you can reuse the handleOpenModal
and handleCloseModal
. No matter how many modals your container has, the only thing that needs to be specified is the modalName
of the modal you would like to see opened.
app.component.html
1<!-- Use the async pipe to get the latest broadcasted value of on observable as an input in the component -->
2<template-modal [modalState]="this.openedModalName$ | async" (onCloseModal)="handleCloseModal()"></template-modal>
3<button class="btn btn-outline-primary" (click)="handleOpenModal('templateFormModal')">Open modal with template</button>
4
5<!-- Don't forget to add this: -->
6<template ngbModalContainer></template>
In this case handleOpenModal
is dispatched by clicking a button, but it can also be dispatched as an output from another component, a directive,a service or an effect. The possibilities are endless.
Including alerts in the application state gives control when, where and how alerts can appear. Since alerts are dependent on either server-side or user actions, their place in the application state is well-deserved.
Unlike other examples in this guide, "reduxifying" alerts is somehwat easy - they can be simply represented as a local collection of items that can be added and removed from the state.
By default, an alert would have two attributes: message
and type
. Here is how the model of a alert would look like:
1export class Alert {
2 message: string;
3 type: string;
4}
First, let's add actions for removing and adding alerts:
layout.actions.ts
1export const LayoutActionTypes = {
2 ADD_ALERT: "[Layout] add alert",
3 REMOVE_ALERT: "[Layout] remove alert"
4 //...
5};
6
7//...
8export class AddAlertAction implements Action {
9 type = LayoutActionTypes.ADD_ALERT;
10 constructor(public payload: Object) {}
11}
12
13export class RemoveAlertAction implements Action {
14 type = LayoutActionTypes.REMOVE_ALERT;
15 constructor(public payload: Object) {}
16}
17//...
18
19export type LayoutActions = AddAlertAction | RemoveAlertAction;
Second, let's add the alerts
slice in the layout
state:
layout.reducer.ts
1import * as layout from "./layout.actions";
2
3export interface State {
4 //...
5 alerts: Array<Object>;
6}
7
8const initialState: State = {
9 //...
10 alerts: []
11};
12
13export function reducer(
14 state = initialState,
15 action: layout.LayoutActions
16): State {
17 switch (action.type) {
18 case layout.LayoutActionTypes.ADD_ALERT: {
19 return Object.assign({}, state, {
20 alerts: [...state.alerts, action.payload]
21 });
22 }
23 case layout.LayoutActionTypes.REMOVE_ALERT: {
24 return Object.assign({}, state, {
25 /*
26 Alerts are filtered by message content, but for real-world usage, an 'id' field would be more suitable.
27 */
28 alerts: state.alerts.filter(
29 alert => alert["message"] !== action.payload["message"]
30 )
31 });
32 }
33 //...
34 default:
35 return state;
36 }
37}
38
39//...
40/*
41 If you add more attributes to the alerts such as 'position' or 'modelType',
42 there can be more selectors added that can filter the collection and allow
43 only certain to be displayed in designated places in the application.
44*/
45export const getAlerts = (state: State) => state.alerts;
Finally, let's add a selector for alerts
in the root:
index.ts
1//...
2export const getLayoutAlertsState = createSelector(
3 getLayoutState,
4 fromLayout.getAlerts
5);
That's it. Now alerts are part of the application state. But how are they going to be used? Let's find out:
With the tools given, making dismissible alerts requires a very small amount of code since ng-bootstrap already offers an implementation. Thus, the only thing that is required is a reusable component to be made that can be used in various places in the application:
1$ touch src/app/alerts-list.component.ts
alerts.component.ts
1import { Component, Input, EventEmitter, Output } from "@angular/core";
2
3@Component({
4 selector: "alerts-list",
5 templateUrl: "alerts-list.component.html"
6})
7export class AlertsListComponent {
8 @Input() alerts: any;
9 @Output() closeAlert = new EventEmitter();
10
11 constructor() {}
12}
The component accepts an array of alerts and outputs the alert which the user decides to close.
1$touch src/app/alerts-list.component.html
alerts.component.html
1<p *ngFor="let alert of alerts">
2 <ngb-alert [type]="alert.type" (close)="closeAlert.emit(alert)">{{ alert.message }}</ngb-alert>
3</p>
Don't forget to add the component to the root module:
app.module.ts
1import { AlertsListComponent } from "./components/alerts-list.component";
2///...
3
4@NgModule({
5 declarations: [AlertsListComponent]
6 //...
7})
8export class AppModule {}
Next, the logic in the container component has to be implemented to select alerts and dispatch events.
app.component.ts
1import { Component, OnInit } from "@angular/core";
2import { Store } from "@ngrx/store";
3import { Observable } from "rxjs";
4import * as fromRoot from "./common/index";
5import * as layout from "./common/layout/layout.actions";
6
7@Component({
8 selector: "app-root",
9 templateUrl: "./app.component.html"
10})
11export class AppComponent implements OnInit {
12 public alerts$: Observable<any>;
13
14 constructor(private store: Store<fromRoot.AppState>) {
15 this.alerts$ = store.select(fromRoot.getLayoutAlertsState);
16 }
17
18 addAlert(alert) {
19 this.store.dispatch(new layout.AddAlertAction(alert));
20 }
21
22 onCloseAlert(alert: Object) {
23 this.store.dispatch(new layout.RemoveAlertAction(alert));
24 }
25}
To demonstrate how alerts look, the template will have two buttons for opeaning different types of alerts:
app.component.html
1<div id="fade" class="fade-in"></div>
2<left-sidebar></left-sidebar>
3<right-sidebar></right-sidebar>
4
5<div id="main-content">
6 <!-- List of alerts goes here -->
7 <alerts-list [alerts]="alerts$ | async (closeAlert)="onCloseAlert($event)"></alerts-list>
8
9 <!-- Buttons for creating alerts -->
10 <button class="btn btn-danger" (click)="addAlert({type: 'danger', message: 'This is a danger alert'})">Add a danger alert</button>
11 <button class="btn btn-success" (click)="addAlert({type: 'success', message: 'This is a success alert'})">Add a success alert</button>
12</div>
In a real-world scenario, alerts can be created when a server returns certain results. For example, in the snippet below, an AddAlertAction
is called once the application resolves a server-side request:
1 @Effect() deleteStudent = this._actions.ofType(student.ActionTypes.DELETE_STUDENT)
2 .switchMap((action) => this._service.delete(action.payload)
3 )
4 .mergeMap(
5 () => {
6 return Observable.from([
7 new DeleteStudentSuccessAction(),
8 /* Chain actions - once the server successfully
9 deletes some model, create an alert from it.
10 */
11 new layout.AddAlertAction({type:'success', message: 'Student successfully deleted!')
12 ])
13 .catch(() => {
14 new layout.AddAlertAction({type:'danger', message: 'An error ocurred.'})
15 return Observable.of( new DeleteStudentFailureAction()
16 })
17 );
18 }
19 );
To make the window size usable in the application state, it has to be updated every time the window is resized. Let's add an action for that:
layout.actions.ts
1import { Action } from "@ngrx/store";
2
3export const LayoutActionTypes = {
4 // Add indow resize action
5 RESIZE_WINDOW: "[Layout] Resize window"
6};
7
8export class ResizeWndowAction implements Action {
9 type = LayoutActionTypes.RESIZE_WINDOW;
10 constructor(public payload: Object) {}
11}
12
13export type LayoutActions = ResizeWndowAction;
To implement the window size in the UI state, there need to be two attributes added to the state - windowWidth
and windowHeight
:
layout.reducer.ts
1import * as layout from "./layout.actions";
2
3export interface State {
4 //...
5 windowHeight: number;
6 windowWidth: number;
7}
8
9const initialState: State = {
10 //...
11 windowHeight: window.screen.height,
12 windowWidth: window.screen.width
13};
14
15export function reducer(
16 state = initialState,
17 action: layout.LayoutActions
18): State {
19 switch (action.type) {
20 /*
21 Window resize case
22 */
23 case layout.LayoutActionTypes.RESIZE_WINDOW: {
24 const height: number = action.payload["height"];
25 const width: number = action.payload["width"];
26 return Object.assign({}, state, {
27 windowHeight: height,
28 windowWidth: width
29 });
30 }
31 //...
32
33 default:
34 return state;
35 }
36}
37
38export const getWindowWidth = (state: State) => state.windowWidth;
39export const getWindowHeight = (state: State) => state.windowHeight;
The inital state is set by using window.screen.height
and window.screen.width
, which get the values of the window's size when the state is initialized. The WindowResizeAction
payload contains an object with the height and the width of the resized window:
{width:number , height:number}
.
There are numerous ways to listen for window resize changes, but perhaps one of the most convenient and conventional ones is to put a host
attribute in the application's root component decorator (Appcomponent
). Since the listener is attached to the root component, ResizeWindowAction
will be dispatched regardless of which part in the application the user is.
app.component.ts
1import { Component, OnInit } from "@angular/core";
2import { Store } from "@ngrx/store";
3import { Observable } from "rxjs";
4import * as fromRoot from "./common/index";
5import * as layout from "./common/layout/layout.actions";
6
7@Component({
8 selector: "app-root",
9 templateUrl: "./app.component.html",
10 styleUrls: ["./app.component.css"],
11 /*
12 Add this to your AppComponent to listen for window resize events
13 */
14 host: {
15 "(window:resize)": "onWindowResize($event)"
16 }
17})
18export class AppComponent implements OnInit {
19 //...
20 constructor(private store: Store<fromRoot.AppState>) {
21 //...
22 }
23
24 ngOnInit() {}
25
26 onWindowResize(event) {
27 this.store.dispatch(
28 new layout.ResizeWndowAction({
29 width: event.target.innerWidth,
30 height: event.target.innerHeight
31 })
32 );
33 }
34}
The host
listens for window resize events and calls the onWindowResize
method with the event as a parameter. The method gets the new sizes using the event.target
property and dispatches a ResizeWindowAction
with the new values.
The most obiquitous case for using the window size is responsiveness. For example, suppose the left sidebar has to automatically close if the window width is lower than 768px (iPad width). Doing this with Redux is quite simple - just add an if statement to the corresponding case:
layout.reducer.ts
1//...
2
3export function reducer(
4 state = initialState,
5 action: layout.LayoutActions
6): State {
7 switch (action.type) {
8 //...
9 case layout.LayoutActionTypes.RESIZE_WINDOW: {
10 const height: number = action.payload["height"];
11 const width: number = action.payload["width"];
12
13 // If the width is lower than 768px, assign false. Otherwise don't change the state
14 const leftSidebarState = width < 768 ? false : state.leftSidebarOpened;
15
16 return Object.assign({}, state, {
17 windowHeight: height,
18 windowWidth: width,
19 leftSidebarOpened: leftSidebarState
20 });
21 }
22 }
23 //...
24}
The equivalent jQuery operation would be quite frustrating. However, with Redux, simply adding a simple ternary operator in the reducer does the trick.
With Redux, all the logic is isolated in a single place, and it is easy to debug and test all your implementations.
Even though pagination is not strictly part of an application's UI layout, it is an integral part of the application's UI that can be implemented with Redux. The goal of implementing server-side pagination is to utilize the application store as much as possible and achieve flexibility with the least code possible.
To illustrate how Redux pagination works, we will use the GiantBomb API as the source of information. We will fetch the games stored in the GiantBomb database, and then we will paginate the results. The pagination will be controlled by the application state.
First, create a separate directory for games
:
1$ mkdir src/app/common/games
1 $ touch src/app/common/games.actions.ts
games.actions.ts
1import { type } from "../util";
2import { Action } from "@ngrx/store";
3export const GameActionTypes = {
4 /*
5 Because the games collection is asynchronous, there need to be actions to handle
6 each of the stages of the request.
7 */
8 LOAD: "[Games] load games",
9 LOAD_SUCCESS: "[Games] successfully loaded games",
10 LOAD_FAILURE: "[Games] failed to load games"
11};
12
13export class LoadGamesAction implements Action {
14 type = GameActionTypes.LOAD;
15 constructor(public payload: any) {}
16}
17
18export class LoadGamesFailedAction implements Action {
19 type = GameActionTypes.LOAD_FAILURE;
20
21 constructor() {}
22}
23export class LoadGamesSuccessAction implements Action {
24 type = GameActionTypes.LOAD_SUCCESS;
25 constructor(public payload: any) {}
26}
27
28export type GameActions =
29 | LoadGamesAction
30 | LoadGamesFailedAction
31 | LoadGamesSuccessAction;
Redux has a convention for loading asynchronous results. It does it by using three actions - LOAD
, LOAD_SUCCESS
and LOAD_FAILURE
. The last two get dispatched when the middleware resolves the server-side request.
To figure out how to construct the state of the paginated games
entities, let's see what a pagination needs:
Having this in mind, here's how the state interface should look:
1export interface State {
2 loaded: boolean;
3 loading: boolean;
4 entities: Array<any>;
5 count: number;
6 page: number;
7}
Let's see how the full implementation looks like:
1$ touch src/app/common/games.reducer.ts
games.reducer.ts
1import { createSelector } from "reselect";
2import * as games from "./games.actions";
3
4export interface State {
5 loaded: boolean;
6 loading: boolean;
7 entities: Array<any>;
8 count: number;
9 page: number;
10}
11
12const initialState: State = {
13 loaded: false,
14 loading: false,
15 entities: [],
16 count: 0,
17 page: 1
18};
19
20export function reducer(
21 state = initialState,
22 action: games.GameActions
23): State {
24 switch (action.type) {
25 case games.GameActionTypes.LOAD: {
26 const page = action.payload;
27
28 return Object.assign({}, state, {
29 loading: true,
30 /*
31 If there is no page selected, use the page from the initial state
32 */
33 page: page == null ? state.page : page
34 });
35 }
36
37 case games.GameActionTypes.LOAD_SUCCESS: {
38 const games = action.payload["results"];
39 const gamesCount = action.payload["number_of_total_results"];
40
41 return Object.assign({}, state, {
42 loaded: true,
43 loading: false,
44 entities: games,
45 count: gamesCount
46 });
47 }
48
49 case games.GameActionTypes.LOAD_FAILURE: {
50 return Object.assign({}, state, {
51 loaded: true,
52 loading: false,
53 entities: [],
54 count: 0
55 });
56 }
57 default:
58 return state;
59 }
60}
61/*
62 Selectors for the state that will be later
63 used in the games-list component
64 */
65export const getEntities = (state: State) => state.entities;
66export const getPage = (state: State) => state.page;
67export const getCount = (state: State) => state.count;
68export const getLoadingState = (state: State) => state.loading;
Every time LOAD
is called from the GamesActions
, the page number is contained within the action's payload
and it is then assigned to the state. What's left is to find a way to query the server
using page
from the games state. To do this, the state has to be added to the application store:
index.ts
1import * as fromGames from "./games/games.reducer";
2//...
3export interface AppState {
4 layout: fromLayout.State;
5 games: fromGames.State;
6}
7
8export const reducers = {
9 layout: fromLayout.reducer,
10 games: fromGames.reducer
11};
12
13//...
14/*
15 Games selectors
16 */
17export const getGamesState = (state: AppState) => state.games;
18export const getGamesEntities = createSelector(
19 getGamesState,
20 fromGames.getEntities
21);
22export const getGamesCount = createSelector(getGamesState, fromGames.getCount);
23export const getGamesPage = createSelector(getGamesState, fromGames.getPage);
24export const getGamesLoadingState = createSelector(
25 getGamesState,
26 fromGames.getLoadingState
27);
getGamesPage
will be used to obtain the current page and send it as a parameter in the query to the service.
1$ touch src/app/common/games.service.ts
games.service.ts
1import { Injectable, Inject } from "@angular/core";
2import { Response, Http, Headers, RequestOptions, Jsonp } from "@angular/http";
3import { Store } from "@ngrx/store";
4import * as fromRoot from "../index";
5
6@Injectable()
7export class GamesService {
8 public page: number;
9
10 constructor(private jsonp: Jsonp, private store: Store<fromRoot.AppState>) {
11 /*
12 Get the page from the games state
13 */
14 store.select(fromRoot.getGamesPage).subscribe(page => {
15 this.page = page;
16 });
17 }
18
19 /*
20 Get the list of games. GiantBomb requires a jsnop request with a token. You can use this token
21 as a present from me, the author, and use it in moderation!
22 */
23 query() {
24 let pagination = this.paginate(this.page);
25 let url = `http://www.giantbomb.com/api/games/?api_key=b89a6126dc90f68a87a6fe1394e64d7312b242da&?&offset=${
26 pagination.offset
27 }&limit=${pagination.limit}&format=jsonp&json_callback=JSONP_CALLBACK`;
28 return this.jsonp.request(url, { method: "Get" }).map(res => {
29 return res["_body"];
30 });
31 }
32 /**
33 * This function converts a page to a pagination
34 * query.
35 *
36 * @param page
37 *
38 * @returns {{offset: number, limit: number}}
39 */
40
41 paginate(page: number) {
42 let beginItem: number;
43 let endItem: number;
44 // Items per page are hardcoded, but you can make them dynamic by adding another parameter
45 let itemsPerPage: number = 10;
46 if (page == 1) {
47 beginItem = 0;
48 } else {
49 beginItem = (page - 1) * itemsPerPage;
50 }
51 return {
52 offset: beginItem,
53 limit: itemsPerPage
54 };
55 }
56}
The currently selected page is taken from the state and passed through paginate
. paginate
is a utility function that converts the current page to offset
and limit
parameters in accordance with the GiantBomb API requirements for paginating results.
Next, let's implement the middleware that will be used to call the service and dispatch
SUCCESS
or FAILURE
actions.
1$ touch src/app/common/games.effects.ts
games.effects.ts
1import "rxjs/add/operator/map";
2import "rxjs/add/operator/catch";
3import "rxjs/add/operator/switchMap";
4import { Observable } from "rxjs/Observable";
5import { Injectable } from "@angular/core";
6import * as games from "./games.actions";
7import { Actions, Effect } from "@ngrx/effects";
8import { GamesService } from "./games.service";
9import { LoadGamesSuccessAction } from "./games.actions";
10import { LoadGamesFailedAction } from "./games.actions";
11
12@Injectable()
13export class GameEffects {
14 constructor(private _actions: Actions, private _service: GamesService) {}
15
16 @Effect()
17 loadGames$ = this._actions
18 .ofType(games.GameActionTypes.LOAD)
19 .switchMap(() =>
20 this._service.query().map(games => {
21 return new LoadGamesSuccessAction(games);
22 })
23 )
24 .catch(() => Observable.of(new LoadGamesFailedAction()));
25}
Lastly, import the EffectsModule
from ngrx/effects
and run the effects and
add GamesService
as a provider:
app.module.ts
1import { EffectsModule } from "@ngrx/effects";
2import { GameEffects } from "./common/games/games.effects";
3import { GamesService } from "./common/games/games.service";
4//...
5@NgModule({
6 //...
7 imports: [
8 //...
9 EffectsModule.run(GameEffects)
10 ],
11 providers: [GamesService],
12 bootstrap: [AppComponent]
13})
14export class AppModule {}
This implementation of pagination provides a great deal of convenience and efficiency - the application state is used to both to represent the state in the client and also give instructions to the server what results to fetch.
To demostrate the requirements for making a reusable and paginatable list component and to see the pagination in action, we will implement a games-list
component.
As mentioned earlier, four "slices" of a state need to be present for pagination to be possible:
Let's create the template of the games-list
component first:
1$ ng g component games-list
games-list.component.ts
1import { Component, OnInit, Input, EventEmitter, Output } from "@angular/core";
2
3@Component({
4 selector: "games-list",
5 templateUrl: "games-list.component.html"
6})
7export class GamesListComponent {
8 /*
9 The minimim required inputs of a list component using redux
10 */
11 @Input() games: any;
12 @Input() count: number;
13 @Input() page: number;
14 @Input() loading: boolean;
15 /*
16 Emit and event when the user clicks on another page
17 */
18 @Output() onPageChanged = new EventEmitter<number>();
19
20 constructor() {}
21}
games-list.component.html
1<div class="container" *ngIf="games">
2 <table class="table table-hover">
3 <thead class="thead-inverse" >
4 <tr>
5 <th>Name</th>
6 </tr>
7 </thead>
8 <tbody>
9 <tr *ngFor="let game of games" >
10 <td>{{game?.name}}</td>
11 </tr>
12 </tbody>
13 </table>
14 <ngb-pagination [collectionSize]="count" [(page)]="page" (pageChange)="onPageChanged.emit($event)" [maxSize]="10" [disabled]="loading"></ngb-pagination>
15</div>
Declare the component in the application module:
app.module.ts
1import {GamesListComponent} from "./components/games-list.component";
2//..
3@NgModule({
4 //...
5 declarations: [
6 //...
7 GamesListComponent,
8 ],
9 //...
10})
GamesListComponent
uses the ngbPagination component which comes in the ng-bootstrap library. The component gets the @Input
s to render a pagination and the pageChange
event triggers the onPageChanged
@output
to emit to the container component.
Next, let's modify the container component (AppComponent
in this case).
To have a working pagination, the container component needs to:
GamesListComponent
onPageChanged
output.app.component.ts
1import * as games from "./common/games/games.actions";
2//...
3
4@Component({
5 selector: "app-root",
6 templateUrl: "./app.component.html",
7 styleUrls: ["./app.component.css"]
8})
9export class AppComponent implements OnInit {
10 //...
11 public games$: Observable<any>;
12 public gamesCount$: Observable<number>;
13 public gamesPage$: Observable<number>;
14 public gamesLoading$: Observable<boolean>;
15
16 constructor(private store: Store<fromRoot.AppState>) {
17 /*
18 Select all the parts of the state needed for the GamesListComponent
19 */
20 this.games$ = store.select(fromRoot.getGamesEntities);
21 this.gamesCount$ = store.select(fromRoot.getGamesCount);
22 this.gamesPage$ = store.select(fromRoot.getGamesPage);
23 this.gamesLoading$ = store.select(fromRoot.getGamesLoadingState);
24 }
25 /*
26 When the component initializes, render the first page ofresults
27 */
28 ngOnInit() {
29 this.store.dispatch(new games.LoadGamesAction(1));
30 }
31
32 //...
33 onGamesPageChanged(page: number) {
34 this.store.dispatch(new games.LoadGamesAction(page));
35 }
36}
Lastly, add GamesListComponent
's selector to AppComponent
's template:
app.component.html
1<div id="main-content">
2 <!-- ... -->
3 <games-list [games]="games$ | async" [count]="gamesCount$ | async" [page]="gamesPage$ | async" [loading]="gamesLoading$ | async" (onPageChanged)="onGamesPageChanged($event)"></games-list>
4</div>
The async
pipe uses the latest value of the observables, watches for state changes, and passes them as inputs.
Here is how pagination works in action:
These examples represent many of the use cases that you might encounter when building an Angular 2 application using Redux. In a larger sense, they provide a boilerplate for more specific use cases and hopefully give new ideas for implementing other use cases.
Does Redux do a good job in controlling the UI layout? In my opinion, it absolutely does. It may require a little bit more code to be written at times, but the benefits truly start to shine as the application's codebase grows and logic gets reused.
Missed anything? I have uploaded the source code with all the examples on Github..