Torsten Müller

A TypeScript pagination component with immutable state

published Oct 22nd, 2019

For a recent project, I had to implement a mechanism which would allow to filter, sort and paginate data sets. Having had problems in the past where parameters were changed from the outside by someone else’s code, I decided to apply a bit of functional programming magic and implement the class handling the data processing settings as an immutable object.

Why immutable data? What is it? What are its advantages?

When I started out with functional programming, I thought “what is the use of immutable objects, when you can’t modify data in them?” I didn’t understand back then that instead of mutating state, immutable objects create a new instance of the same class with the mutated data.

In JavaScript, string and integer are always passed as values, whereas objects and arrays are passed by reference. That means that any change to any object reference modifies the properties for all references. The following code demonstrates that behavior:

example.js
const a = {test: 1};
const b = a; 
console.log('a=', a, 'b=', b); // prints "a= {test: 1}, b= {test: 1}"
a.test = 42;
console.log('b=', b); // prints "b= {test: 42}"

Here, we have two references to the same object instance, a and b. Since they are references, when I change a property on a, I also change b at the same time. If I look at more complex code, I might not be aware that the object in b is also pointed to by another variable and will be surprised by the change in value.

Immutable objects, as the name suggests, behave differently, since none of their properties can be changed — not from the outside nor from a method of the class. In order to be able to use them and “change” the values of properties, we have to create a whole new instance of an immutable class, with the required properties set to the new values.

For immutable objects there is usually a method that allows to create a new instance with the changed state, in Scala it is the copy() method. So if we pass a modification to that method, we receive a new, different instance of the class, containing the mutated data.

In the following section I will develop an immutable data class with the same behavior outlined here, namely a copy() method to return an object of the same class but with different data.

Implementation of an immutable class in TypeScript

The following example shows an immutable DataDisplaySettings class. It is using various data types (FilterValues, SortOption and PagingParams), whose details are irrelevant for this discussion and are not further explored in this post.

data-display-setting.ts
type DataModifier = FilterValues | SortOption | PagingParams;

export const defaultParams = {
  filterSettings: {},
  sortSettings: {
    sortType: SortType.String,
    sortBy: '',
    sortOrder: SortOrders.NONE
  },
  paginationSettings: {
    totalCount: 0,
    pageSize: environment.rowsPerPage,
    currentPage: 1
  }
};
type DataProcessingParams = {
  filters: FilterValues,
  sorting: SortOption,
  paging: PagingParams
};

export class DataDisplaySettings {

  private readonly filterSettings: FilterValues;
  private readonly sortSettings: SortOption;
  private readonly paginationSettings: PagingParams;

  constructor(pageOpts?: DataProcessingParams) {
    this.initField(pageOpts, 'filters', 'filterSettings');
    this.initField(pageOpts, 'sorting', 'sortSettings');
    this.initField(pageOpts, 'paging', 'paginationSettings');
  }

  private initField(dataObj: DataProcessingParams, field: string, propertyName: string): void {
    if (dataObj && dataObj[field]) {
      this[propertyName] = Object.freeze(dataObj[field]);
    } else {
      this[propertyName] = Object.freeze(defaultParams[propertyName]);
    }
  }

  get filters(): FilterValues { return this.filterSettings }
  get sorting(): SortOption { return this.sortSettings }
  get paging(): PagingParams { return this.paginationSettings }

  /**
   * Method to return a new instance of this class containing the modified data
   */
  public copy(updateSettings: DataProcessingParams): DataDisplaySettings {
    const newObjectParams: DataProcessingParams = {
      filters: this.cloneAndUpdateSettings<FilterValues>(updateSettings.filters, 'filterSettings'),
      sorting: this.cloneAndUpdateSettings<SortOption>(updateSettings.sorting, 'sortSettings'),
      paging: this.cloneAndUpdateSettings<PagingParams>(updateSettings.paging, 'paginationSettings')
    };
    return new DataDisplaySettings(newObjectParams);
  }

  private cloneAndUpdateSettings<T extends DataModifier>(newSettingsObj: T, property: string): T {
    return newSettingsObj ? Object.freeze(Object.assign({}, newSettingsObj)) : this[property];
  }
}

We see a number of readonly properties in lines 24-26. These properties are set in the constructor by the initField() method, which takes either

  1. a passed configuration object as dataObj: DataProcessingParams or
  2. uses the default parameters listed before the class definition in lines 3-15

To populate the respective properties. In the initField() method, you notice that we’re using the JavaScript Object.freeze() method to prohibit the changing of the contained data. This works in our case, because the objects are constituted of simple types. If we had assigned an array or another object to one of the nested properties, that nested object would not be frozen and still allow mutation.

We then define some getters in lines 42-44 and then get into the heart of the class, the copy() method starting in line 49. That method accepts an object which reflects the desired new state of the system and therefore the value of the properties of this class.

The first thing the method does is create a new instance of an object of type DataProcessingParams and then proceeds to populate the properties using the cloneAndUpdateSettings() method. To that latter method, it passes

  1. the data object for the desired property as the first parameter and
  2. the key name to populate in the object as the second parameter.

After the generation of that configuration object, we instantiate a new instance of the DataDisplaySettings class with the modified data on line 55.

The cloneAndUpdateSettings() method used in copy() either returns the currently set object if no settings object for that parameter is passed or, if an object of type DataModifier is passed as the second argument, it clones the object into an empty object literal and then calls Object.freeze() to prevent further modification of the object. This effectively “updates” the values for that setting.

Usage

So, how do you use that thing we just created? Here is an example for changing the page of data displayed in a table:

example.ts
let display = new DataDisplaySettings();
const valueChanges = {
  paging: {
    totalCount: display.paging.totalCount,
    pageSize: display.paging.pageSize,
    currentPage: 4 
  }
};
display = display.copy(valueChanges);

In line 1, we create a new DataDislaySettings instance with the default values, which can be seen in the previous listing in lines 3-15. This line is a mandatory step when setting up a view to get the pagination functionality initialized.

So now, when a user clicks on the “page 4” button of the table’s pagination feature, we need to update our setting. Since we’re not changing the sorting or filtering settings, we leave those out of our change object. The cloneAndUpdateSettings() method is smart enough to check for missing parameters and assume we don’t want to change them. We do need to provide a new instance of type PagingParams though, which is created on lines 3-7, where we set the current page to page 4.

Immutability and its benefits

In the last line, line 9 we use the copy() method of the original pagination object to generate a new pagination object. As seen before, copy() invokes the cloneAndUpdateSettings() method once for each setting, which creates a new immutable object for each provided parameter object and reuses the current parameter objects for settings we didn’t specify (filters and sorting). It does, however, copy and freeze() our submitted paging object literal before assigning it to the paging setting in the new object.

A word of caution about the Object.freeze() method: While it freezes the immediate properties of an object and thereby prevent mutation, the case isn’t as clear-cut for objects and arrays. These data types are kept by reference, so a freeze has no effect on mutation of any contained properties which are themselves arrays or objects. These nested entities will remain mutable.

So now, we have a new instance of the page display configuration stored in the display variable. If we’re using a framework which features change detection, like Angular, our immutable implementation would automatically trigger a page update and rerender of the pagination display, since the object reference has changed — even as we’d have to use ngOnChanges() to react to the change and load new data from the server.

That last paragraph sounds esoteric, but it’s not. Angular by default only compares properties that are used in its templates:

By default, Angular Change Detection works by checking if the value of template expressions have changed. This is done for all components.

and

By default, Angular does not do deep object comparison to detect changes, it only takes into account properties used by the template

These quotes from Angular University demonstrate the usefulness of the immutable object paradigm. If we change an object property that affects our business logic but is not directly displayed in the template, the standard Angular change detection will not kick off a change event.

Through our immutable implementation, we solve that problem, since we’re substituting the referenced object in the class in its entirety, and so, since the entire object has changed, the change detection will be triggered.

Summary

In this post, I have demonstrated how to implement immutable data objects in JavaScript, relying only on standard methods of JavaScript’s Object. I briefly explained the benefit of immutable data in the Angular framework as well as the short-coming of the freeze() method when it comes to nested objects.