Ionic Page and component relationship

Good morning everybody and good coding!

I’m building an app using Ionic 6.17.1 and I’m facing what I think is a basic concept but I’m not able to figure it out regarding the use of a page and the use of a component. I read the documentation but I’m a little confused and I think I’m mixing things a little bit…

I’ve created 3 different pages, we’ll call it tasks due, open and scheduled, each of them has very similar information, so to reuse the code I decided to decouple the visualization of a list of tasks and the visualization of an individual task I created 2 components: show and list.

Each component receives an @Input parameter, task for the show and elements for the list, It seems prety straight forward… and I used on the app using tabs, on each page (due, open and scheduled) the following code to display a component show or a component list:

<app-list *ngIf="dataLoaded" 
        [elements]="openItems" 
        title="Open Items"
        source="TabOpen"
        (item)="showItem($event)"
    ></app-list>

and in case a showItem is clicked, I show:

 <div *ngIf="mode == 'show' ">
    <app-show *ngIf="item" 
        [item]="item"
        (backEvent)="showList($event)"
        ></app-show>

Now the problem or mis-conception: If I start the app it shows the first tab and a list of tasks, and if click on a task it shows the component show, as it should.

If I come back it works perfectly well also, but everything falls apart when I click a second tab (page due for example) and then I come back to the open tab and then click on a task.

It’s like the tab-open is called twice, like overlapping. In fact If then I click on the back button I see that the tab-open is called but I don’t go back, and on a second click it works:

TabOpen::Did Enter [tab-open.page.ts:52:12](webpack:///src/app/tab-open/tab-open.page.ts)
TabOpen::showList(): triggered [tab-open.page.ts:142:12](webpack:///src/app/tab-open/tab-open.page.ts)
TabOpen::showList(): end. [tab-open.page.ts:150:12](webpack:///src/app/tab-open/tab-open.page.ts)
TabOpen::showList(): triggered [tab-open.page.ts:142:12](webpack:///src/app/tab-open/tab-open.page.ts)
TabOpen::showList(): end. [tab-open.page.ts:150:12](webpack:///src/app/tab-open/tab-open.page.ts)
Component::list(): On Init [list.component.ts:29:12](webpack:///src/app/components/list/list.component.ts)
Source: TabOpen

The question: is a correct architecture to have multiple pages each of them re-using the same components? or is an aberration and it’s the reason everything starts falling apart?

Thanks in advance.

There should be no problem is re-using components, that is the point of components :slight_smile:

Do you have an IonPage on each page wrapping everything? The IonPage component is required in order for Ionic to navigate/transition correctly.

<ion-page>
    <app-list>
        <!-- stuff -->
    </app-list>
</ion-page>

Can you show the code in a representative page that populates the page properties that are bound to the @Input property in these components? I suspect that the underlying problem might have nothing whatsoever to do with pages and components, but rather a result of trying to use component lifecycle to manage data, such as in this thread.

Incidentally, @twestrick : isn’t that React syntax? I think OP is using Angular here.

Well, Vueish syntax. My bad if Angular is completely different.

Yeah, there hasn’t really been an “IonPage” concept on the Angular side for a while now, since Ionic embraced using the Angular router directly. As long as something is decorated as a Component() and the router knows what routes map to it, you’re good to go.

1 Like

Yes, sure… the code for example for tab-open:

import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { AlertController, LoadingController, ToastController, ViewDidEnter, ViewDidLeave, ViewWillEnter } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';

import { ApiService } from '../services/api.service';
import { GlobalService } from '../services/global.service';

import { Category } from '../models/category.model';
import { Item } from '../models/item.model';
import { Step } from '../models/step.model';
import { Router } from '@angular/router';

@Component({
  selector: 'app-tab-open',
  templateUrl: './tab-open.page.html',
  styleUrls: ['./tab-open.page.scss'],
})
export class TabOpenPage implements OnInit, ViewDidEnter, ViewDidLeave {

  mode: string;
  alert;
  dataLoaded: Promise<boolean>|null = null;

  openItems: Category[];
  item: Item|null;

  constructor(
    private api: ApiService,
    private global: GlobalService,
    private translate: TranslateService,
    private router: Router,
    private loadingCtrl: LoadingController,
    private alertCtrl: AlertController,
    private toastCtrl: ToastController) { }

  ngOnInit() {
    console.log("TabOpen::On Init");
    if( this.global.currComponent == null ){
      this.global.currComponent = 'list';
      this.global.currPage = 'open';
    }
    this.mode = this.global.currComponent;
  }

  ionViewDidEnter(){
    console.log("TabOpen::Did Enter");
       
    if( this.mode == 'list'){
      console.log("TabOpen::Did Enter Reloading list");
      this.loadData();
    }

  }

  ionViewDidLeave(){
    console.log("TabOpen::Did Leave");
    // Called when click on due
  }

  async loadData(){
    this.dataLoaded =  Promise.resolve(false);
    await this.global.sessionLoaded;
    let loadingString='';

    this.translate.setDefaultLang(this.global.language);
    this.translate.use(this.global.language);

    this.translate.get('LOADING').subscribe((res: string) => {
          loadingString = res;
        });

    this.global.loading = await this.loadingCtrl.create({
      message: loadingString+'...'
    });

    console.log("TabOpen::loadData(): session loaded ");

    let itemsLoaded = new Promise((resolve, reject)=>{

      this.global.loading.present().then(() => {
          this.api.getOpenItems().subscribe(res => {

          console.log("TabOpen::loadData(): OpenItems loaded "); 
          let resData: any;
          resData = res;
          resData = resData.data;

          this.openItems = resData;

          this.global.categories = resData;

          this.global.loading.dismiss();
          resolve(true);

        }, err => {
          console.log("Error: "+ JSON.stringify(err) );
            this.alert('Error', err.message);
            this.global.loading.dismiss();
            setTimeout(async () => {
              this.global.loading.dismiss();
              //this.input1.setFocus();
            }, 2000);
          reject(false);
        });
      });

    });

    await itemsLoaded;
    console.log('TabOpen::loadData(): Items loaded');

    this.dataLoaded =  Promise.resolve(true);

  }

  doRefresh( event ){
    this.loadData();
    event.target.complete();
  }

  showItem( event: any ){
    console.log("TabOpen::showItem() triggered");
    this.global.currPage='open';
    this.global.currComponent='show';
    this.item = event;
    this.mode = 'show';
  }

  showList( event: any){
    console.log("TabOpen::showList() triggered");
    this.global.currPage='open';
    this.global.currComponent='list';
    this.item = null;
    this.mode = 'list';
  }
}

And the tab-open.html:

<ion-content [fullscreen]="true">

  <div *ngIf="mode == 'list' ">
    <ion-header collapse="condense">
      <ion-toolbar>
        <ion-title size="large">Open Items</ion-title>
      </ion-toolbar>
    </ion-header>  
       
    <ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
      <ion-refresher-content
        pullingIcon="chevron-down-circle-outline"
        pullingText="Pull to refresh"
        refreshingSpinner="circles"
        refreshingText="Refreshing...">
      </ion-refresher-content>
    </ion-refresher>
  
    <app-list *ngIf="dataLoaded" 
        [elements]="openItems" 
        title="Open Items"
        source="TabOpen"
        (item)="showItem($event)"
    ></app-list>
  </div>

  <div *ngIf="mode == 'show' ">
    <ion-header [translucent]="true">
      <ion-toolbar *ngIf=" item ">
        <ion-buttons slot="start">
          <h1><ion-icon name="chevron-back-outline" (click)="showList(item)"></ion-icon></h1>
        </ion-buttons>      
        <ion-title>
          {{ item.name }}
        </ion-title>
      </ion-toolbar>
    </ion-header>

    <app-show *ngIf="item" 
        [item]="item"
        (backEvent)="showList($event)"
        ></app-show>

        <ion-tabs>
          <ion-tab-bar slot="bottom" id="show-tabs">
            <ion-tab-button *ngFor="let step of item.nextSteps" (click)="execStep(step.id)">
              <ion-icon name="{{step.icon}}"></ion-icon>
              <ion-label>{{step.description}}
                </ion-label>
            </ion-tab-button>
            <ion-tab-button (click)="delete()">
              <ion-icon name="trash-outline"></ion-icon><ion-label>borrar</ion-label>
            </ion-tab-button>
          </ion-tab-bar>
        </ion-tabs>          
      </div>
</ion-content>

And the show.component.ts:

import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AlertController, LoadingController, NavController, ToastController, ViewDidEnter, ViewDidLeave, ViewWillEnter } from '@ionic/angular';
import { ApiService } from 'src/app/services/api.service';
import { GlobalService } from 'src/app/services/global.service';

import { Comment } from 'src/app/models/comment.model';
import { Item } from 'src/app/models/item.model';
import { TranslateService } from '@ngx-translate/core';

@Component({
  selector: 'app-show',
  templateUrl: './show.component.html',
  styleUrls: ['./show.component.scss'],
})
export class ShowComponent implements OnInit, ViewDidEnter, ViewDidLeave {

  @Input() item: Item;

  @Output() backEvent= new EventEmitter<Item>();

  editName: boolean = false;
  editDescription: boolean = false;
  comment: string;
 

  constructor(
      private global: GlobalService,
      private api: ApiService,
      private translate: TranslateService,
      private loadingCtrl: LoadingController,
      private alertCtrl: AlertController,
      private toastCtrl: ToastController,
      private navCtrl: NavController,
      private route: ActivatedRoute
  ) { }

  ngOnInit() {
    console.log("Component::show(): On Init");
    if( this.item === undefined ){
      this.item = this.global.item;
    }

  }

  ionViewDidEnter(){
    console.log("Component::show(): Did Enter");
  }

  ionViewDidLeave(){
    console.log("Component::show(): Did Leave");
  }

  switchDescription(){
    this.editDescription = true;
  }

  switchName(){
    this.editName = true;
  }

  async updateItem(){

    this.global.loading = await this.loadingCtrl.create({
      message: 'Cargando....'
    });

    this.global.loading.present().then(() => {

      this.api.putItem( this.item ).subscribe( res => {

          this.global.loading.dismiss();
          this.editDescription = false;
          this.editName = false;

        }, err => {
          this.alert('Error', err.error.message);

          this.global.loading.dismiss();
          this.editDescription = false;
          this.editName = false;

        });

    });

  }

  async createComment(){
    console.log("Comment: "+this.comment);
    if( this.comment.length > 0 ){
      this.api.newComment(this.item.id, this.comment).subscribe( res => {
          //this.updateData();
          let comment = new Comment();
          comment.content = this.comment;
          this.item.comments.push(comment);
      });
      this.comment= '';
      //this.updateData();
    }else {
      this.toast('Write a comment before sending it...');
      this.comment= '';
    }
  }

  async alert(title, message) {
      const alert = await this.alertCtrl.create({
        header: title,
        message: message,
        buttons: ['Ok']
      });
      await alert.present();
  }

  async delete(){
    console.log("ShowPage::delete()");
    const alert = await this.alertCtrl.create({
      cssClass: 'my-custom-class',
      header: 'Alert',
      subHeader: 'Delete?',
      message: 'Are you sure you want to delete the item?',
      buttons: [{
          text: 'Cancel',
          role: 'cancel',
          cssClass: 'secondary',
          handler: () => {
            console.log('Confirm Cancel');
          }
        }, {
          text: 'Delete',
          handler: () => {
            console.log('Confirm delete');
            this.sendDelete();
          }
        }]
    });

    await alert.present();

  }

  async sendDelete(){
    this.global.loading = await this.loadingCtrl.create({
      message: 'Cargando..'
    });
    this.global.loading.present().then(() => {

      this.api.deleteItem(this.item.id).subscribe( res => {
      this.global.loading.dismiss();
      this.navCtrl.navigateRoot('/tabs');

      });

    });
  }

  async toast(msg) {
      let toast = this.toastCtrl.create({
        message: msg,
        duration: 3000,
        position: 'bottom'
      });
      (await toast).present();
  }

  back(){
    this.backEvent.emit(this.item);
    this.item = null;
  }

}

And finally the show.component.html:

  <ion-card *ngIf="item">
    <ion-card-title>
      {{ item.name }}
      <ion-icon name="create-outline" (click)="switchName()"></ion-icon>      
    </ion-card-title>

    <ion-card-content>
      <ion-row>
        <ion-item *ngIf=" editName ">
          <form (ngSubmit)="updateItem()">
              <ion-textarea [(ngModel)]="item.name" name="name" rows="3"></ion-textarea>
              <ion-button ion-button type="submit" block>Actualizar nombre</ion-button>
           </form>
        </ion-item>
      </ion-row>
      <ion-row>
        <ion-col size="9" size-xs="12" *ngIf="item.parent">
          <span>Parent: <u>{{ item.parent.name }}</u></span>
        </ion-col>
        <ion-col size="3" size-xs="12" style="text-align: right">
          <ion-badge color="{{item?.statusLabel}}">{{item.statusName}}</ion-badge>
          <ion-badge color="{{item?.dueState }}">{{ item.dueDate }}</ion-badge>
        </ion-col>
      </ion-row>
    </ion-card-content>
  </ion-card>

Hmm. That GlobalService looks like a huge problem to me. It’s almost a “shadow router”, and it’s relying on incorrect assumptions about lifecycle events and tabs. Is there any possibility you can try rearchitecting things so that GlobalService doesn’t exist?

There’s also an instance of the explicit promise construction antipattern in loadData. Hopefully the sample code in the other thread I linked in my previous post can give you some ideas on how to do that without manually making unnecessary Promises.

Thanks rapropos for all the feedback. It’s greatly appreciated!

The GlobalService was not used at the beggining to ‘route’ and was created only to store session information for all the pages, but I started to use it when I was not able to control my workflow of information between the tabs and the components.

I’ll clean it up and I’ll post again with the new code.

I’ll take a deep look at the links to see if I can ‘integrate’ the knowledge into my linear-mind :-D… I see that the strenght of Angular comes from this possibilities of paralel processing, but it’s not an easy thing to understand if you come from the old-fashion programming languages.

Thanks again to everybody for the answers!

Ok, meanwhile here we are again with the clean-up code. Since I used a controlled commit it was easy to clean the mess…

tab-open.ts:

import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { AlertController, LoadingController, ToastController, ViewDidEnter, ViewDidLeave, ViewWillEnter } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';

import { ApiService } from '../services/api.service';
import { GlobalService } from '../services/global.service';

import { Category } from '../models/category.model';
import { Item } from '../models/item.model';
import { Step } from '../models/step.model';
import { Router } from '@angular/router';

@Component({
  selector: 'app-tab-open',
  templateUrl: './tab-open.page.html',
  styleUrls: ['./tab-open.page.scss'],
})
export class TabOpenPage implements OnInit, ViewDidEnter, ViewDidLeave {

  mode: string;
  alert;
  dataLoaded: Promise<boolean>|null = null;

  openItems: Category[];
  item: Item|null;

  constructor(
    private api: ApiService,
    private global: GlobalService,
    private translate: TranslateService,
    private router: Router,
    private loadingCtrl: LoadingController,
    private alertCtrl: AlertController,
    private toastCtrl: ToastController) { }

  ngOnInit() {
    console.log("TabOpen::On Init");
    this.mode = 'list';
  }

  ionViewDidEnter(){
    console.log("TabOpen::Did Enter");
       
    if( this.mode == 'list'){
      console.log("TabOpen::Did Enter Reloading list");
      this.loadData();
    }

  }

  ionViewDidLeave(){
    console.log("TabOpen::Did Leave");
    // Called when click on due
  }

  async loadData(){
    this.dataLoaded =  Promise.resolve(false);
    await this.global.sessionLoaded;
    let loadingString='';

    this.translate.setDefaultLang(this.global.language);
    this.translate.use(this.global.language);

    this.translate.get('LOADING').subscribe((res: string) => {
          loadingString = res;
        });

    this.global.loading = await this.loadingCtrl.create({
      message: loadingString+'...'
    });

    console.log("TabOpen::loadData(): session loaded ");

    let itemsLoaded = new Promise((resolve, reject)=>{

      this.global.loading.present().then(() => {
          this.api.getOpenItems().subscribe(res => {

          console.log("TabOpen::loadData(): OpenItems loaded "); 
          let resData: any;
          resData = res;
          resData = resData.data;

          this.openItems = resData;

          this.global.categories = resData;

          this.global.loading.dismiss();
          resolve(true);

        }, err => {
          console.log("Error: "+ JSON.stringify(err) );
            this.alert('Error', err.message);
            this.global.loading.dismiss();
            setTimeout(async () => {
              this.global.loading.dismiss();
              //this.input1.setFocus();
            }, 2000);
          reject(false);
        });
      });

    });

    await itemsLoaded;
    console.log('TabOpen::loadData(): Items loaded');

    this.dataLoaded =  Promise.resolve(true);

  }

  doRefresh( event ){
    this.loadData();
    event.target.complete();
  }

  showItem( event: any ){
    console.log("TabOpen::showItem() triggered");
    this.item = event;
    this.mode = 'show';
  }

  showList( event: any){
    console.log("TabOpen::showList() triggered");
    this.item = null;
    this.mode = 'list';
  }

}

show.ts component:

import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AlertController, LoadingController, NavController, ToastController, ViewDidEnter, ViewDidLeave, ViewWillEnter } from '@ionic/angular';
import { ApiService } from 'src/app/services/api.service';
import { GlobalService } from 'src/app/services/global.service';

import { Comment } from 'src/app/models/comment.model';
import { Item } from 'src/app/models/item.model';
import { TranslateService } from '@ngx-translate/core';

@Component({
  selector: 'app-show',
  templateUrl: './show.component.html',
  styleUrls: ['./show.component.scss'],
})
export class ShowComponent implements OnInit, ViewDidEnter, ViewDidLeave {

  @Input() item: Item;

  @Output() backEvent= new EventEmitter<Item>();

  editName: boolean = false;
  editDescription: boolean = false;
  comment: string;
 

  constructor(
      private global: GlobalService,
      private api: ApiService,
      private translate: TranslateService,
      private loadingCtrl: LoadingController,
      private alertCtrl: AlertController,
      private toastCtrl: ToastController,
      private navCtrl: NavController,
      private route: ActivatedRoute
  ) { }

  ngOnInit() {
    console.log("Component::show(): On Init");
    if( this.item === undefined ){
      this.item = this.global.item;
    }

  }

  ionViewDidEnter(){
    console.log("Component::show(): Did Enter");
  }

  ionViewDidLeave(){
    console.log("Component::show(): Did Leave");
  }

  switchDescription(){
    this.editDescription = true;
  }

  switchName(){
    this.editName = true;
  }

  async updateItem(){

    this.global.loading = await this.loadingCtrl.create({
      message: 'Cargando....'
    });

    this.global.loading.present().then(() => {

      this.api.putItem( this.item ).subscribe( res => {

          this.global.loading.dismiss();
          this.editDescription = false;
          this.editName = false;

        }, err => {
          this.alert('Error', err.error.message);

          this.global.loading.dismiss();
          this.editDescription = false;
          this.editName = false;

        });

    });

  }

  async createComment(){
    console.log("Comment: "+this.comment);
    if( this.comment.length > 0 ){
      this.api.newComment(this.item.id, this.comment).subscribe( res => {
          //this.updateData();
          let comment = new Comment();
          comment.content = this.comment;
          this.item.comments.push(comment);
      });
      this.comment= '';
      //this.updateData();
    }else {
      this.toast('Write a comment before sending it...');
      this.comment= '';
    }
  }

  async alert(title, message) {
      const alert = await this.alertCtrl.create({
        header: title,
        message: message,
        buttons: ['Ok']
      });
      await alert.present();
  }

  async delete(){
    console.log("ShowPage::delete()");
    const alert = await this.alertCtrl.create({
      cssClass: 'my-custom-class',
      header: 'Alert',
      subHeader: 'Delete?',
      message: 'Are you sure you want to delete the item?',
      buttons: [{
          text: 'Cancel',
          role: 'cancel',
          cssClass: 'secondary',
          handler: () => {
            console.log('Confirm Cancel');
          }
        }, {
          text: 'Delete',
          handler: () => {
            console.log('Confirm delete');
            this.sendDelete();
          }
        }]
    });

    await alert.present();

  }

  async sendDelete(){
    this.global.loading = await this.loadingCtrl.create({
      message: 'Cargando..'
    });
    this.global.loading.present().then(() => {

      this.api.deleteItem(this.item.id).subscribe( res => {
      this.global.loading.dismiss();
      this.navCtrl.navigateRoot('/tabs');

      });

    });
  }

  async toast(msg) {
      let toast = this.toastCtrl.create({
        message: msg,
        duration: 3000,
        position: 'bottom'
      });
      (await toast).present();
  }

  back(){
    this.backEvent.emit(this.item);
    this.item = null;
  }
}

The behaviour follows:

  • I can enter and come back to the list on the open issues without problem multiple times
  • I can click on the tabs and move without problems
  • Once I’ve come back to the tab-open, if I click on a task to see the show.component it appears but on the back of it I can see the list.component (see picture below)

that is the reason I started to ‘externalize’ to the global service the status, to ‘force’ my bad code to behave… :sweat_smile:

I think you’ve really nailed the fundamental issue here, and trust me, I struggled with it for months, having spent 30+ years working in imperative languages myself.

It helped me to completely invert my thinking, often writing “backwards” or “bottom-up”, where the most rudimentary elements get written first. You have an external ApiService, that exposes a method called getOpenItems. That’s great. It also returns an Observable, which is superb, because it allows you to hide important details from the entire rest of the app, including all these pages and components.

I’m assuming, however, that at the moment all getOpenItems does is wrap an HTTP call and return the result of that call. The problem with that is that it pushes the responsibility for maintaining data freshness out to the callers, and you end up with a house of cards built on lifecycle events that aren’t intended for that purpose. This tends to bite people most deeply when using tabs, because tabs don’t fire lifecycle events in a way that works for doing data management (hence the 40 million times I’ve linked that other thread).

There are a number of strategies for data freshness, and I’m not sure which is most appropriate for your use case. Whatever you end up with, though, I would urge you to ensure that it obeys the following contract:

To always have the freshest answer available to the app, all that is needed is to call getOpenItems once. It will never complete, and will emit new data whenever it is received.

That moots pretty much the entirety of loadData in the page, and especially the contortions with GlobalService that determine when and how often that gets called.

1 Like

If you’re still on an experimental branch, maybe this will highlight the problem:

Try eliminating all lifecycle handlers from your pages. No ngOnInit, no ionViewDidEnter. Whatever you have to do on page setup, do in the constructor. I realize that means that you only get to do it once, and that’s precisely the goal.

You likely won’t be able to achieve exactly the results you want without tweaking ApiService, and that’s also a good thing, because that’s where this needs to get fixed. ApiService doesn’t know about component lifecycle, which means it can’t depend on it and shoot itself in the foot.

1 Like

Good morning everybody.

I’ve solved the issues following (and learning) how to ‘behave’ in this new world of non-linear programming.

At the end I redesigned the code to:

  • Cleanup all the events on all my pages
  • Create a new service that manages the local ‘database’ of information, it communicates with the backend to update and receive new items and forwards information to the pages as requested
  • I cleanup also my global service, that it doesn’t route anything artificially
  • I cleanup all the bad uses of ion-tabs, and using them only to redirect to pages and not to create fancy ‘button bars’

I still have to learn how to create functions, as suggested by @rapropos, to maintain the freshness of the data, but the app it’s working, so that will be a battle for another day :smiley:

Thanks everybody for your support and patience, specially @rapropos for it’s altruist feedback and recommendations