A TypeScript pagination component with immutable state
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:
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.
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
- a passed configuration object as
dataObj: DataProcessingParams
or - 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
- the data object for the desired property as the first parameter and
- 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:
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.