Menu

Gestionando el estado de tu aplicación ionic con ngx-model

ionicreduxngx-modelstate-management - September 07, 2018 por Álvaro Izquierdo | Edita este Post.
ionic4.1.1

Usar la librería ngx-store para gestionar el estado de nuestra aplicación ionic/angular no siempre es lo más aconsejable. El uso de esta librería para implementar Redux en ocasiones se hace complejo en proyectos pequeños o medianos. Para estos casos tenemos varias alternativas para gestionar y actualizar el estado de una aplicación Ionic de forma más sencilla pero teniendo unos de los conceptos básicos de Redux, una única “fuente de verdad” y la potencia de los observables y subject. Hoy vamos a ver una de esta alternativas The Angular Model - ngx-model.

Gestionar el estado de una aplicación frontend es una de las tareas más importantes y delicadas. Una buena gestión de este es fundamental para tener una aplicación limpia, escalable y que no nos de muchos quebraderos de cabeza cuando queramos implementar nuevas funcionalidades. Para esta gestión existen cada vez más librerias que nos permiten usar la potencia de los Observables y de los Subject.

En este artículo vamos a ver como realizar y gestionar el estado de una aplicación pequeña implementando el patrón que nos propone la librería ngx-model. La aplicación dispone de varias pantallas. En primer lugar tendremos una lista de conferencias desde la que podremos acceder al detalle de la conferencia donde, a parte de los datos de esta, tendremos un listado de speakers, en el que podremos añadir nuevos, editar los existentes o eliminarlos.

1. Que es ngx-model

ngx-model es una librería para hacer un manejo simple del estado de nuestra aplicación. Para ello nos propone una API mínima, un solo flujo de datos, soporte para multiples modelos y expone los datos con RxJS Observables.

En definitiva, crearemos un servicio o provider para cada uno de nuestros modelos de datos, en nuestra aplicación de ejemplo tendremos dos: conferences y speakers. Y todas las “acciones” que realicemos sobre estos dos modelos las haremos en estos servicios o providers. Tras cada acción se actualizará el estado de nuestros modelos, o mejor dicho el estado de nuestra aplicación.

2. Nota sobre la API

Vamos a usar el sevicio mock API para simular nuestra api y poder realizar las peticiones que vamos a necesitar.

3. Generar una nueva aplicación Ionic

En primer lugar crearemos una nueva aplicación Ionic en blanco. Como ya hemos comentado, la aplicación tendrá dos páginas, ConferenceListPage donde veremos una lista de conferencias y ConferenceDetailPage donde mostraremos los detalles de la conferencia así como un listado de los speaker de esta.

ionic start ngx-model-conference-app blank 

4. Generamos las páginas de la aplicación

Ejecutamos los siguientes comandos para generar las dos páginas de nuestra aplicación de ejemplo.

ionic g page conference-list
ionic g page conference-detail
ionic g page speaker-form

5. Instalamos ngx-model

Para instalar ngx-model ejecutamos:

npm install --save ngx-model

o

yard add ngx-model

y lo importamos en nuestro /src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';

import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';

import { NgxModelModule } from 'ngx-model';

@NgModule({
  declarations: [
    MyApp,
    HomePage
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    NgxModelModule
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler}
  ]
})
export class AppModule {}

6. Generamos los servicios o providers

Para nuestra aplicación de ejemplo vamos a generar dos providers, conferences y speakers, uno para cada uno de nuestros modelos de datos.

ionic g provider conferences
ionic g provider speakers

7. Cambiamos nuestra página de inicio

Sustituimos la página Home que por defecto viene como página inicial por la nueva página que hemos creado, ConferenceListPage. Para ello hacemos unos cambios en /src/app/app.component.ts

import { Component } from '@angular/core';
import { Platform } from 'ionic-angular';
import { StatusBar } from '@ionic-native/status-bar';
import { SplashScreen } from '@ionic-native/splash-screen';

@Component({
  templateUrl: 'app.html'
})
export class MyApp {
  rootPage:any = 'ConferenceListPage';

  constructor(platform: Platform, statusBar: StatusBar, splashScreen: SplashScreen) {
    platform.ready().then(() => {
      // Okay, so the platform is ready and our plugins are available.
      // Here you can do any higher level native things you might need.
      statusBar.styleDefault();
      splashScreen.hide();
    });
  }
}

Recordar que ya no es necesario importar las página ConferenceListPage ya que estamos usando lazy loading.

Y eliminamos la importación de HomePage que nos crea automaticamente Ionic CLI al generar el proyecto en el fichero /src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';

import { HttpClientModule } from '@angular/common/http';

import { MyApp } from './app.component';

import { NgxModelModule } from 'ngx-model';
import { ConferencesProvider } from '../providers/conferences/conferences';
import { SpeakersProvider } from '../providers/speakers/speakers';

@NgModule({
  declarations: [
    MyApp,
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    HttpClientModule,
    NgxModelModule
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler},
    ConferencesProvider,
    SpeakersProvider
  ]
})
export class AppModule {}

8. Creamos las interfaces para nuestros datos

Vamos a crear dos interfaces sencillas para el modelo Conference y el modelo Speaker. Para esto creamos la carpeta /src/interfaces.

/src/interfaces/conference.interface.ts

export interface Conference {
  id: string;
  name: string;
  imageUrl: string;  
}

/src/interfaces/speaker.interface.ts

export interface Speaker {
  id: string;
  name: string;
  avatar?: string;
  conferenceId: number; 
}

Nota: El atributo avatar lo vamos a poner como opcional ya que vamos a dejar que nuestra API de prueba nos lo añada solo.

9. Configuramos los provider al estilo ngx-model

El provider conferences solo va a obtener los datos de las conferencias de la API. Una vez obtenidos actualizará el estado del modelo para de este modo actualizar el estado de nuestra app.

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

import { ModelFactory, Model } from 'ngx-model';

import { Conference } from './../../interfaces/conference.interface';

@Injectable()
export class ConferencesProvider {

  private model: Model<Conference[]>
  conferences$: Observable<Conference[]>

  endpoint = 'http://5b9204a24c818e001456e89f.mockapi.io/conferences'

  constructor(
    public http: HttpClient,
    private modelFactory: ModelFactory<Conference[]>
  ) {
    
    this.model = this.modelFactory.create([]);
    this.conferences$ = this.model.data$;

  }

  getConferences() {
    return this.http.get(this.endpoint)
      .pipe(
        tap((conferences: Conference[]) => {
          this.model.set(conferences);
        })
      );
  }

}

En el provider speakers si tendremos las típicas acciones para crear, actualizar y eliminar un speaker.

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { tap, switchMap } from 'rxjs/operators';

import { ModelFactory, Model } from 'ngx-model';

import { Speaker } from './../../interfaces/speaker.interface';

@Injectable()
export class SpeakersProvider {

  private model: Model<Speaker[]>;
  speakers$: Observable<Speaker[]>;

  endpoint = 'http://5b9204a24c818e001456e89f.mockapi.io/speakers'

  constructor(
    public http: HttpClient,
    private modelFactory: ModelFactory<Speaker[]>
  ) {
    
    this.model = this.modelFactory.create([]);
    this.speakers$ = this.model.data$;

  }

  getSpeakers() {
    return this.http.get(this.endpoint)
      .pipe(
        tap((speakers: Speaker[]) => {
          this.model.set(speakers);
        })
      );
  }


  removeSpeaker(speaker: Speaker) {
    const url = `${this.endpoint}/${speaker.id}`;

    return this.http.delete(url)
      .pipe(
        switchMap(() => {
          return this.getSpeakers();
        })
      );
  }

  updateSpeaker(speaker: Speaker) {
    const url = `${this.endpoint}/${speaker.id}`; 
    
    return this.http.put(url, speaker)
      .pipe(
        switchMap(() => {
          return this.getSpeakers();
        })
      );
  }

  createSpeaker(speaker: any) {
     
    return this.http.post(this.endpoint, speaker)
      .pipe(
        switchMap(() => {
          return this.getSpeakers();
        })
      );
  }

}

Ahora el estado de los speaker reside en el servicio cada vez que actualicemos ese estado o array de speaker todo los “observadores” de ese stream de datos, es decir, los componentes subscritos se actualizarán.

Vamos a ver más en detalle el método createSpeaker.

  createSpeaker(speaker: any) {
     
    return this.http.post(this.endpoint, speaker)
      .pipe(
        switchMap(() => {
          return this.getSpeakers();
        })
      );
  }

Aquí lo que hacemos es hacer una petición post a la API para añadir un nuevo speaker, una vez que la API nos responde encadenamos la petición con el operador SwitchMap para realizar un getSpeaker y de esta manera actualizar el estado de nuestro modelo dando como consecuencia que los componentes subscritos se actualicen. Esta acción la prodriamos hacer de muchas maneras, en este caso, como la aplicación de ejemplo es sencilla la hemos realizado así para también mantener el estado de nuestra app sincronizada con el estado de la API.

10. Visualización de datos en los componentes Pages

La página del listado de conferencias se quedaría de esta manera.

/src/pages/conference-list/conference-list.ts

import { Component } from '@angular/core';
import { IonicPage, NavController, NavParams } from 'ionic-angular';
import { Observable } from 'rxjs/Observable';

import { ConferencesProvider } from './../../providers/conferences/conferences';
import { Conference } from '../../interfaces/conference.interface';

@IonicPage()
@Component({
  selector: 'page-conference-list',
  templateUrl: 'conference-list.html',
})
export class ConferenceListPage {

  conferences$: Observable<Conference[]>

  constructor(
    public navCtrl: NavController,
    public navParams: NavParams,
    private conferencesProv: ConferencesProvider
  ) { }

  ionViewWillLoad() {
    
    this.conferences$ = this.conferencesProv.conferences$;

    // Start data flow
    this.conferencesProv.getConferences().subscribe();
    
  }

  goToDetail(conference: Conference) {
    this.navCtrl.push('ConferenceDetailPage', {conference: conference});
  }

}

/src/pages/conference-list/conference-list.html


<ion-header>

  <ion-navbar>
    <ion-title>Conferences</ion-title>
  </ion-navbar>

</ion-header>


<ion-content padding>
  
  <ion-list>
    <ion-item *ngFor="let conference of (conferences$ | async)" (tap)="goToDetail(conference)">
      <ion-thumbnail item-start>
        <img [src]="conference.imageUrl">
      </ion-thumbnail>
      <h2>{{ conference.name }}</h2>
    </ion-item>
  </ion-list>

</ion-content>

La página del detalle de una conferencia tiene algo más de código, pero espero que sea fácil de seguir.

/src/pages/conference-detail/conference-detail.ts

import { Component } from '@angular/core';
import { IonicPage, NavController, NavParams, ModalController, AlertController } from 'ionic-angular';
import { Observable } from 'rxjs/Observable';
import { map } from 'rxjs/operators';

import { SpeakersProvider } from './../../providers/speakers/speakers';
import { Conference } from '../../interfaces/conference.interface';
import { Speaker } from '../../interfaces/speaker.interface';

@IonicPage()
@Component({
  selector: 'page-conference-detail',
  templateUrl: 'conference-detail.html',
})
export class ConferenceDetailPage {

  conference: Conference;
  speakers$: Observable<Speaker[]>;

  constructor(
    public navCtrl: NavController,
    public navParams: NavParams,
    private speakersProv: SpeakersProvider,
    private modalCtrl: ModalController,
    private alertCtrl: AlertController
  ) { }

  ionViewWillLoad() {

    this.conference = this.navParams.get('conference');

    this.speakers$ = this.speakersProv.speakers$
      .pipe(
        map((speakers: Speaker[]) => {
          return speakers.filter((speaker: Speaker) => speaker.conferenceId === parseInt(this.conference.id));          
        })
      );

    // Start speakers data flow
    this.speakersProv.getSpeakers().subscribe();
  }

  removeSpeaker(speaker: Speaker) {
    let alert = this.alertCtrl.create({
      title: 'Information',
      subTitle: 'Are you sure?',
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel'
        },
        {
          text: 'Ok',
          handler: () => {
            this.speakersProv.removeSpeaker(speaker).subscribe();
          }
        }
      ]
    });

    alert.present();
  }

  updateSpeaker(speaker: Speaker) {
    let modal = this.modalCtrl.create('SpeakerFormPage', { speaker: speaker });
    
    modal.onWillDismiss((data) => {
      if (data && data.speaker) {
        this.speakersProv.updateSpeaker(data.speaker).subscribe();
      }
    });

    modal.present();
  }
  
  createSpeaker() { 
    let modal = this.modalCtrl.create('SpeakerFormPage', { speaker: null });
    
    modal.onWillDismiss((data) => {
      if (data && data.speaker) {
        data.speaker.conferenceId = parseInt(this.conference.id);
        this.speakersProv.createSpeaker(data.speaker).subscribe();
      }
    });

    modal.present();
  }

}

/src/pages/conference-detail/conference-detail.html


<ion-header>

  <ion-navbar>
    <ion-title>Conference Detail</ion-title>
  </ion-navbar>

</ion-header>


<ion-content padding>
  <h3>{{ conference?.name }}</h3>
  
  <h4>Speakers</h4>

  <ion-list>
    <ion-item *ngFor="let speaker of (speakers$ | async)">
      <ion-avatar item-start>
        <img [src]="speaker.avatar">
      </ion-avatar>
      <h2>{{ speaker.name }}</h2>
      <p>
        <button ion-button small color="primary" (tap)="updateSpeaker(speaker)">
          <ion-icon name="create"></ion-icon>
        </button>
        <button ion-button small color="danger" (tap)="removeSpeaker(speaker)">
          <ion-icon name="trash"></ion-icon>
        </button>
      </p>
    </ion-item>
  </ion-list>

  <ion-fab bottom right>
    <button ion-fab mini (tap)="createSpeaker()"><ion-icon name="add"></ion-icon></button>
  </ion-fab>
</ion-content>

Quiero detenerme en la subscripción al modelo del provider Speakers

this.speakers$ = this.speakersProv.speakers$
      .pipe(
        map((speakers: Speaker[]) => {
          return speakers.filter((speaker: Speaker) => speaker.conferenceId === parseInt(this.conference.id));          
        })
      );

Como en definitiva el modelo del provider speakers (speaker$) es un observable podemos utilizar cualquiera de los operadores de estos. En este caso usamos el operador map para filtrar los resultados por el conferenceId de la conferencia que estamos visualizando, pero podriamos utilizar operadores más complejos.

11. Configuramos el modal SpeakerFormPage para añadir y editar a los speaker

Para la actualización de los datos de nuestros speakers he decido hacer un pequeño formulario en un componente para usarlo tanto para la creación como para la actualización.

/src/pages/speaker-form/speaker-form.ts

import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { IonicPage, NavParams, ViewController } from 'ionic-angular';

import { Speaker } from '../../interfaces/speaker.interface';

@IonicPage()
@Component({
  selector: 'page-speaker-form',
  templateUrl: 'speaker-form.html',
})
export class SpeakerFormPage {

  speaker: Speaker;
  form: FormGroup;

  constructor(
    public navParams: NavParams,
    private viewCtrl: ViewController,
    private fb: FormBuilder
  ) { }

  ionViewWillLoad() {
    
    this.speaker = this.navParams.get('speaker') || {};

    this.form = this.fb.group({
      name: [this.speaker.name || '', Validators.required],
      id: [this.speaker.id || null],
      conferenceId: [this.speaker.conferenceId || null]
    });
  }

  submit() {
    if (this.form.valid) {
      this.dismiss(this.form.value);
    }
  }

  dismiss(newSpeaker: Speaker = null) {
    this.viewCtrl.dismiss({speaker: newSpeaker});
  }

}

/src/pages/speaker-form/speaker-form.html

<ion-header>

  <ion-navbar>
    <ion-title>Speaker</ion-title>
    <ion-buttons start>
      <button ion-button icon-only (tap)="dismiss()">
        <ion-icon name="close"></ion-icon>
      </button>
    </ion-buttons>
  </ion-navbar>

</ion-header>


<ion-content padding>

  <form [formGroup]="form" *ngIf="form" (submit)="submit()">
    <ion-list>

      <ion-item>
        <ion-label stacked>Name</ion-label>
        <ion-input type="text" formControlName="name"></ion-input>
      </ion-item>

    </ion-list>

    <button type="submit" ion-button block>Save</button>
  </form>

</ion-content>

12. Conclusiones

Gestionar el estado de la aplicación en un solo sitio es algo que nos facilita la vida a los desarrolladores frontend. En el caso de ngx-model no tenemos una sola fuente de verdad como propone la arquitectura Redux, sino que gestionamos el estado de cada uno de nuestros modelos de datos de forma independiente pero se asemeja mucho a patrón propuesto por Redux. Lo bueno de está librería es que usa toda la potencia de los Observables y Subject al exponer los datos con lo que no tenemos que estar preocupandonos de actualizar los componentes subscritos a los distintos modelo cada vez que realizamos una acción sobre estos.

Ver código

Recuerda:

Si quieres ejecutar este demo en tu computadora, debes tener el entorno de ionic instalado y luego de descargar o clonar el proyecto debes ubicarte en tu proyecto en la terminal y luego ejecutar

npm install

esto es para descargar todas las dependencias del proyecto.

¡Compártelo!