Published: December 15 2022

Angular - Trigger ngOnChanges in a Child Component for Array or Object

Tutorial built with Angular 14.2.12

This is a quick post to show to trigger the Angular OnChanges lifecycle hook in a child component for a data-bound array or object.

The example code is from a Angular paging and sorting tutorial I posted recently which triggers the ngOnChanges() method in a child <pagination> component to reset to the first page when table data is sorted. For the full paging/sorting tutorial see Angular 14 - Paging and Sorting Table Data Example & Tutorial.


Angular OnChanges

The Angular OnChanges hook is triggered when any data-bound property (e.g. [someProp]="someValue") of a component changes.

Changes not detected on array or object

There's a catch, when a data-bound property points to an array or object Angular change detection only checks if the reference has changed, so modifying the contents of the array or properties of the object will not trigger ngOnChanges().

I ran into this issue because the Array sort() method updates the array in-place meaning the reference still points to the same array, so changes aren't detected by Angular.


Trigger ngOnChanges for Array or Object

The solution turned out to be pretty simple, copy the contents into a new array or object with the javascript spread operator (...). This creates a new reference which triggers Angular change detection to call ngOnChanges().

Here's how to copy an Angular property of type array or object with the spread operator:

// copy array prop to trigger change detection
this.myArr = [...this.myArr];

// copy object prop to trigger change detection
this.myObj = {...this.myObj};


Pagination component with ngOnChanges() method

Path: /src/app/_components/pagination.component.ts

This is the example pagination component mentioned above that implements the Angular OnChanges interface. The ngOnChanges() method sets the component to the initial page by calling this.setPage(this.initialPage) when the items array is changed (including when items are first loaded).

The setPage() method sets which page of items to display. It executes the paginate() method to get the pager properties for the specified page, slices out the current page of items into a new array (pageOfItems), then emits the changePage event to the parent component with the current page of items.

import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';

@Component({ selector: 'pagination', templateUrl: 'pagination.component.html' })
export class PaginationComponent implements OnChanges {
    @Input() items?: Array<any>;
    @Output() changePage = new EventEmitter<any>(true);
    @Input() initialPage = 1;
    @Input() pageSize = 10;
    @Input() maxPages = 10;

    pager?: Pager;

    ngOnChanges(changes: SimpleChanges) {
        // set page when items array first set or changed
        if (changes.items.currentValue !== changes.items.previousValue) {
            this.setPage(this.initialPage);
        }
    }

    setPage(page: number) {
        if (!this.items?.length)
            return;

        // get new pager object for specified page
        this.pager = this.paginate(this.items.length, page, this.pageSize, this.maxPages);

        // get new page of items from items array
        const pageOfItems = this.items.slice(this.pager.startIndex, this.pager.endIndex + 1);

        // call change page function in parent component
        this.changePage.emit(pageOfItems);
    }

    paginate(totalItems: number, currentPage: number = 1, pageSize: number = 10, maxPages: number = 10): Pager {
        // calculate total pages
        let totalPages = Math.ceil(totalItems / pageSize);

        // ensure current page isn't out of range
        if (currentPage < 1) {
            currentPage = 1;
        } else if (currentPage > totalPages) {
            currentPage = totalPages;
        }

        let startPage: number, endPage: number;
        if (totalPages <= maxPages) {
            // total pages less than max so show all pages
            startPage = 1;
            endPage = totalPages;
        } else {
            // total pages more than max so calculate start and end pages
            let maxPagesBeforeCurrentPage = Math.floor(maxPages / 2);
            let maxPagesAfterCurrentPage = Math.ceil(maxPages / 2) - 1;
            if (currentPage <= maxPagesBeforeCurrentPage) {
                // current page near the start
                startPage = 1;
                endPage = maxPages;
            } else if (currentPage + maxPagesAfterCurrentPage >= totalPages) {
                // current page near the end
                startPage = totalPages - maxPages + 1;
                endPage = totalPages;
            } else {
                // current page somewhere in the middle
                startPage = currentPage - maxPagesBeforeCurrentPage;
                endPage = currentPage + maxPagesAfterCurrentPage;
            }
        }

        // calculate start and end item indexes
        let startIndex = (currentPage - 1) * pageSize;
        let endIndex = Math.min(startIndex + pageSize - 1, totalItems - 1);

        // create an array of pages to ng-repeat in the pager control
        let pages = Array.from(Array((endPage + 1) - startPage).keys()).map(i => startPage + i);

        // return object with all pager properties required by the view
        return {
            totalItems,
            currentPage,
            pageSize,
            totalPages,
            startPage,
            endPage,
            startIndex,
            endIndex,
            pages
        };
    }    
}

export interface Pager {
    totalItems: number;
    currentPage: number;
    pageSize: number;
    totalPages: number;
    startPage: number;
    endPage: number;
    startIndex: number;
    endIndex: number;
    pages: number[];
}


App component template with a <pagination> child component

Path: /src/app/app.component.html

The app component template contains a pageable and sortable table of data. Sorting is triggered by clicking any of the table header columns which are bound to the sortBy() method.

Pagination logic and controls are encapsulated in the <pagination> component. The items array of the app component is bound to the items property of the pagination component with the Angular data binding attribute [items]="items". The onChangePage() method of the app component is bound to the changePage event of the pagination component with the Angular event binding attribute (changePage)="onChangePage($event)". The $event argument is the current page of items (pageOfItems) when the event is emitted from the setPage() method of the pagination component.

<!-- main app container -->
<div class="card m-3">
    <h3 class="card-header text-center">Angular 14 - Paging and Sorting Table Data Example</h3>
    <div class="card-body">
        <table class="table table-striped table-sm">
            <thead>
                <tr>
                    <th><a (click)="sortBy('id')">Id {{sortIcon('id')}}</a></th>
                    <th><a (click)="sortBy('firstName')">First Name {{sortIcon('firstName')}}</a></th>
                    <th><a (click)="sortBy('lastName')">Last Name {{sortIcon('lastName')}}</a></th>
                    <th><a (click)="sortBy('age')">Age {{sortIcon('age')}}</a></th>
                    <th><a (click)="sortBy('company')">Company {{sortIcon('company')}}</a></th>
                </tr>
            </thead>
            <tbody>
                <tr *ngFor="let item of pageOfItems">
                    <td>{{item.id}}</td>
                    <td>{{item.firstName}}</td>
                    <td>{{item.lastName}}</td>
                    <td>{{item.age}}</td>
                    <td>{{item.company}}</td>
                </tr>
                <tr *ngIf="loading">
                    <td colspan="5" class="text-center py-5">
                        <span class="spinner-border spinner-border-lg align-center"></span>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
    <div class="card-footer pb-0 pt-3">
        <pagination [items]="items" (changePage)="onChangePage($event)"></pagination>
    </div>
</div>


App component that triggers ngOnChanges() in child component

Path: /src/app/app.component.ts

The app component contains the logic for the pageable sortable table of data.

You can see the javascript spread operator (...) being used by the sortBy() method to copy the result of this.items.sort() into a new array that is assigned to this.items. As a result Angular will detect the change to the data-bound items array and call ngOnChanges() in the child pagination component.

import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({ selector: 'app-root', templateUrl: './app.component.html' })
export class AppComponent {
    items: any[] = [];
    pageOfItems?: Array<any>;
    sortProperty: string = 'id';
    sortOrder = 1;
    loading = false;

    constructor(private http: HttpClient) { }

    ngOnInit() {
        // fetch items from the backend api
        this.loading = true;
        this.http.get<any[]>('/items')
            .subscribe(x => {
                this.items = x;
                this.loading = false;
            });
    }

    onChangePage(pageOfItems: Array<any>) {
        // update current page of items
        this.pageOfItems = pageOfItems;
    }

    sortBy(property: string) {
        this.sortOrder = property === this.sortProperty ? (this.sortOrder * -1) : 1;
        this.sortProperty = property;
        this.items = [...this.items.sort((a: any, b: any) => {
            // sort comparison function
            let result = 0;
            if (a[property] < b[property]) {
                result = -1;
            }
            if (a[property] > b[property]) {
                result = 1;
            }
            return result * this.sortOrder;
        })];
    }

    sortIcon(property: string) {
        if (property === this.sortProperty) {
            return this.sortOrder === 1 ? '☝️' : '👇';
        }
        return '';
    }
}

 


Need Some Angular Help?

Search fiverr for freelance Angular developers.


Follow me for updates

On Twitter or RSS.


When I'm not coding...

Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!


Comments


Supported by