Improving the Reactivity System (feat. TOAST UI Grid)

TOAST UI
13 min readJan 14, 2020

Improving the Reactivity System (feat. TOAST UI Grid)

The TOAST UI Grid manages the states of involved data by structuring its own reactivity system. TOAST UI Grid can manage the changes in data conveniently due to the fact that the reactivity system automatically detects when any piece of data is changed and updates the properties of other data. Moreover, the reactivity system allows a more concise and declarative code to be used, hence eliminating unnecessary code. However, the reactivity system inevitably suffered in terms of performance when transforming massive regular data into observable data and slowed down the Grid’s initial rendering speed (2.5 seconds per 100k datasets at initial rendering). This article serves to elucidate our efforts and methods to resolve the reactivity system’s performance issue when dealing with large datasets. (This article does not cover the underlying basics of a reactivity system. To learn more, read Building a Reactivity System Similar to Vue in Under 0.7KB).

Lazy Observable

Lazy observable is a tool used to generate observable data only when the data is actually necessary. As I have mentioned in the beginning of this article, generating observable data from massive array datasets is a very costly task, and this task is the very culprit behind slowing down the initial rendering performance. In order to address the problem, we were constantly searching for ways minimize the time it takes to generate observable data and thought ”what if instead of changing the entire dataset to suit the reactivity system at first, we limit the range of objects so that each data is transformed only when it is necessary?” Finally, we decided the range of objects to be transformed into observable data to be what you see on your screen (data within a scrollable area,) and decided to apply lazy observable.

In order to facilitate a better understanding of lazy observable, I will explain it using the Grid’s example images.

The image above is an example of 100k datasets rendered onto the TOAST UI Grid. In such case, is it really necessary for all datasets to be observable? Actually, the only data users need is what appears on the scrollable area. Therefore, it is much more efficient to transform data objects that are crucial to rendering the viewable area into observable data and leave the remainder of datasets to be as they are. This idea is the very concept behind lazy observable. (While we decided the scroll area to be the limit for our data, this will differ for different applications).

Now, let’s explore how TOAST UI Grid implements lazy observable together.

1. Get the List of Objects to be Transformed into Observable Data.

The first task is getting the range of data to be displayed on the screen (to be transformed into observable data.)

function createOriginData(data, rowRange) {
const [start, end] = rowRange;
return data.gridRows.slice(start, end).reduce(
(acc, row, index) => {
// Do not include the data that is already observable
if (!isObservable(row)) {
acc.rows.push(row);
acc.targetIndexes.push(start + index);
}
return acc;
},
{
rows: [],
targetIndexes: []
}
);
}

TOAST UI Grid uses the results from createOriginData function to determine the range of objects to be turned into observable data. The function uses the rowRange, information regarding the row range that is displayed on the screen, to return the row object with its index to be transformed into observable data with respect to the entire grid (data.gridRows.) However, if the object has already been transformed into observable data, the isObservable function will deal with this condition since respective data does not need to be generated again.

2. Transform the Original Data to be Observable.

Since the original data is not yet observable, the system cannot detect the changes and update automatically. Therefore, we need to use the range information returned from the function above to transform the original data to be observable.

function createObservableData({ column, data, viewport }) {
const originData = createOriginData(data, viewport.rowRange);
if (!originData.rows.length) {
return;
}
changeToObservableData(column, data, originData);
}
function changeToObservableData(column, data, originData) {
const { targetIndexes, rows } = originData;
// Generate observable data
const gridRows = createData(rows, column);
for (let i = 0, end = gridRows.length; i < end; i += 1) {
const targetIndex = targetIndexes[i];
data.gridRows.splice(targetIndex, 1, gridRows[i]);
}
}

The changeToObservableData function uses originData, the result of createOriginData function, to generate gridRows, observable datasets. Then, the splice method is used on the original data, data.gridRows, to replace the changed data with the observable data.

3. Detect the Changes in Rendering Range.

Lastly, we need to detect the changes in rendering range. In other words, we need to detect when the scroll has been moved so that we can transform the datasets in the corresponding range to be observable.


observe(() => createObservableData(store));

By performing the observe function on the createObservableData function, the observe function is executed every time the rowRange or gridRows is changed and dynamically modifies the data that is not observable. Since we have already defined the observe function, we can automate the process with a single line of code.

Let’s take a look at the finished code. (Actual code can be found here).

/**
* Get the List of Objects to be Transformed into Observable Data.
*/
function createOriginData(data, rowRange) {
const [start, end] = rowRange;
return data.gridRows.slice(start, end).reduce(
(acc, row, index) => {
// Do not include the data that is already observable
if (!isObservable(row)) {
acc.rows.push(row);
acc.targetIndexes.push(start + index);
}
return acc;
},
{
rows: [],
targetIndexes: []
}
);
}
/**
* Transform the Original Data to be Observable.
*/
function changeToObservableData(column, data, originData) {
const { targetIndexes, rows } = originData;
// Generate observable data
const gridRows = createData(rows, column);
for (let i = 0, end = gridRows.length; i < end; i += 1) {
const targetIndex = targetIndexes[i];
data.gridRows.splice(targetIndex, 1, gridRows[i]);
}
}
export function createObservableData({ column, data, viewport }) {
const originData = createOriginData(data, viewport.rowRange);
if (!originData.rows.length) {
return;
}
changeToObservableData(column, data, originData);
}
/**
* Detect the changes in rendering range and
* dynamically modify the data that is not observable automatically.
*/
observe(() => createObservableData(store));

When we compare rendering 100k datasets, the initial rendering speeds without lazy observable and with lazy observable are 2357ms and 99ms, respectively. With lazy observable, it was nearly 23 times faster.

If you are using a reactivity system in an application that deals with large data, it is imperative that you consider the optimal process of building observable data. Keep in mind that the cost of generating observable data is considerable.

Batch Processing

Batch processing is a well known strategy to handle large number of tasks or data modification tasks in bulk effectively. For our case, we collected a series of tasks that result from updating a single piece of data in a reactivity system into a batch unit so that we can handle it at once.

Let’s explore what kinds of benefits batch processing offers when paired with a reactivity system. 🤔

Effective Update Management

In a reactivity system, when a certain piece of data is changed, other data properties and computed properties are affected serially. If the updated data instigates the display’s layout task or a repaint task, it is clearly more effective to collect such tasks into one unit as to eliminate unnecessary rendering than it is to render after each minuscule update.

Eliminating Duplicate Updates

If there is a drawback to a reactivity system, it is the difficulty detecting duplicates among a series of updates caused by a single update and the trouble with optimizing the process. However, by performing updates in a batch unit, we can at least eliminate the duplicative updates within the collective unit. (The batch unit has been defined in a previous section).

Exploring the TOAST UI Grid’s implementation of the batch processing, we will mainly focus on the actual implementation instead of the observe function. If you are curious about how we implemented the observe function, you can read more about it here.

function callObserver(observerId) {
observerIdStack.push(observerId);
observerInfoMap[observerId].fn();
observerIdStack.pop();
}
function run(observerId) {
callObserver(observerId);
}
function observe(fn) {
// do something
run(observerId);
}

The code above is an example of the original observe function. When the observe function is called, in order to maintain execution priority, we stack the observerId on top of the internally maintained observerIdStack. Then, the observer function is executed. Throughout this process, other observer functions related to the observerId, which is at the top of the stack will be stacked on top, and when all related tasks have finished executing, respective observerId will be removed from the stack in order.

Let’s apply batch processing here and collect said tasks into a single work unit.

let queue = [];
let observerIdMap = {};
function batchUpdate(observerId) {
if (!observerIdMap[observerId]) {
observerIdMap[observerId] = true;
queue.push(observerId);
}
}
function run(observerId) {
batchUpdate(observerId);
}

Upon inspecting the changed code, we can see that instead of directly calling the observer function from the run function, we call the batchUpdate function to push observerId into the queue. In other words, we can consider the queue to be a single batch unit. Also, the interesting aspect in this snippet is that we use the observerIdMap object so that the observerId that has already been handled in the queue is not handled again. With this single line of code, we can prevent duplicate updates.

We can finalize the code by defining a flush function that executes observer functions in the queue as you can see below.

let queue = [];
let observerIdMap = {};
let pending = false;
function batchUpdate(observerId) {
if (!observerIdMap[observerId]) {
observerIdMap[observerId] = true;
queue.push(observerId);
}
if (!pending) {
flush();
}
}
function callObserver(observerId) {
observerIdStack.push(observerId);
observerInfoMap[observerId].fn();
observerIdStack.pop();
}
function clearQueue() {
queue = [];
observerIdMap = {};
pending = false;
}
function flush() {
pending = true;
for (let i = 0; i < queue.length; i += 1) {
const observerId = queue[i];
observerIdMap[observerId] = false;
callObserver(observerId);
}
clearQueue();
}

When the batchUpdate function is called, the function looks at the pending variable and calls the flush function. If the flush function is already being executed within the same batch (if the pending variable is set to true,) it will simply push the observerId onto the queue without calling the flush function. This is the important part. In order to make certain that related updates are all executed without omission, we need to push the observerId onto the queue even when pending. Furthermore, the for statement within the flush function dynamically executes the observer functions according to the length of the queue.

Actually, beloved frameworks and libraries like React, Vue, Preact, and more already offer DOM rendering related optimizations. However, with batch processing, we can eliminate unnecessary operation even before rendering optimization.

Monkey Patch Array

TOAST UI Grid does not generate array data in observable format. As I have explained earlier, generating observable data is a costly task, and this is especially true if the dataset is large. Originally, TOAST UI Grid calls the notify function every time the array data need to be updated in order to call the related observe functions. While the notify function had no particular issues, initially, we ran into a problem that as we added more features, notify function was called by more duplicate codes. Therefore, in order to ameliorate this problem, we searched for ways to call observe functions related to array data automatically without calling the notify function. It was then that we thought of using monkey patch method to solve this issue. (Monkey patching is a technique to dynamically modify a certain object’s properties or methods. Refer to the addressed link to learn more).

Now, let’s investigate how we used monkey patching to solve the issue with TOAST UI Grid’s source code. 🤓

1. Array Data Update Code Before Monkey Patching

As the amount of array data to be updated increase, TOAST UI Grid runs the notify function in order to call the observe functions. Let’s look at the following example.

function appendRow(store, row) {
// do something
rawData.splice(at, 0, rawRow);
viewData.splice(at, 0, viewRow);
heights.splice(at, 0, getRowHeight(rawRow, dimension.rowHeight));
// notify function call
notify(data, 'rawData');
notify(data, 'viewData');
notify(data, 'filteredRawData');
notify(data, 'filteredViewData');
notify(rowCoords, 'heights');
}

It should be obvious that the notify function call appears repetitive. While there was no problem with the performance, the code is still obnoxious, and as functions that cause changes in the array datalike appendRow increase, the repetition will only grow.

2. Monkey Patch Code that Wraps the Array Methods

The array data are the targets of monkey patching, and the purpose is to detect changes in the array data automatically and call the related observe functions. Therefore, only certain methods that cause updates in array data like splice, push, pop and etc. are targets of the wrappers, and other methods like view and return new array objects do not even need to be considered.

Let’s take a look at how we wrapped the methods that cause updates.

const methods = ['splice', 'push', 'pop', 'shift', 'unshift', 'sort'];export function patchArrayMethods(arr, obj, key) {
methods.forEach(method => {
const patchedMethods = Array.prototype[method];
// Monkey patch original methods with the patch function
arr[method] = function patch(...args) {
const result = patchedMethods.apply(this, args);
notify(obj, key);
return result;
};
});
return arr;
}

Within the for statement of the patchArrayMethods function a patch function declaration that updates the array data automatically and calls the notify function. Then, the function monkey patches each item in the arr array using the predefined methods and patch function. While it is also possible to directly modify the Array.prototype, because this could cause unintended errors in other applications, we decided to monkey patch each array object.

3. Monkey Patching Code that Wraps Array Methods

Finally, let’s look at how monkey patching has changed the codes.

function setValue(storage, resultObj, observerIdSet, key, value) {
if (storage[key] !== value) {
if (Array.isArray(value)) {
patchArrayMethods(value, resultObj, key);
}
storage[key] = value;
Object.keys(observerIdSet).forEach(observerId => {
run(observerId);
});
}
}
export function observable(obj) {
// do something
Object.keys(obj).forEach(key => {
// do something
if (isFunction(getter)) {
observe(() => {
const value = getter.call(resultObj);
setValue(storage, resultObj, observerIdSet, key, value);
});
} else {
storage[key] = obj[key];
if (Array.isArray(storage[key])) {
patchArrayMethods(storage[key], resultObj, key);
}
Object.defineProperty(resultObj, key, {
set(value) {
setValue(storage, resultObj, observerIdSet, key, value);
}
});
}
});
return resultObj;
}
function appendRow(store, row) {
// do something
rawData.splice(at, 0, rawRow);
viewData.splice(at, 0, viewRow);
heights.splice(at, 0, getRowHeight(rawRow, dimension.rowHeight));
}

As you can see from the code above, if the observable data from observable function is of array type, the patchArrayMethods is called to wrap the array methods, and every time the array data is updated, related observe function is called automatically. (The source code for the observable function can be found here). Now, there is no longer a need to forcefully call the notify function just to call the observe function. If you look at the new appendRow function, repetitive notify functions are all gone, and the function itself looks a lot neater.

So far I explained how and why we applied monkey patching to TOAST UI Grid reactivity system using code and the process. With it, we eliminated duplicative code to refactor it into a neater code, and the overall quality of the code has improved.

📝 Summary

Let’s summarize what we have talked about so far with respect to the code I explained in the lazy observable section.

function changeToObservableData(column, data, originData) {
const { targetIndexes, rows } = originData;
// Generate observable data
const gridRows = createData(rows, column);
for (let i = 0, end = gridRows.length; i < end; i += 1) {
const targetIndex = targetIndexes[i];
data.gridRows.splice(targetIndex, 1, gridRows[i]);
}
}
export function createObservableData({ column, data, viewport }) {
const originData = createOriginData(data, viewport.rowRange);
if (!originData.rows.length) {
return;
}
changeToObservableData(column, data, originData);
}
observe(() => createObservableData(store));

In order to summarize it even more, the code above revitalizes the necessary data into observable data each time the scroll moves.

Let’s study the code above more deeply.

for (let i = 0, end = gridRows.length; i < end; i += 1) {
const targetIndex = targetIndexes[i];
data.gridRows.splice(targetIndex, 1, gridRows[i]);
}

Because array data within the Grid are not observable, even if you perform splice or push to modify the data, the update does not happen unless you call functions like notify. If you are quick, that’s right. You guessed it. As we saw earlier with monkey patching arrays, because the array methods have been wrapped, updates happen automatically.

This should make you wonder. Does the fact that array data is updated due to the splice method call and the derived updates are executed within the for-loop cause any issues?

There is no need to worry because we implemented batch processing.

observe(() => createObservableData(store));

The createObservableData function is called within the observe function. Therefore, derived updates within the for block are collected into one batch unit, so it does not cause duplicate updates nor does it update every turn.

In the previous sections, we explored the benefits of each method we used. We have briefly went over how the overall flow is. Furthermore, if you are interested in looking at some of the excluded codes, feel free to browse our github.

Warning❗

Reactivity system is incredibly convenient. It uses the observe function to update the data and the view automatically. However, this convenience may lead you to build observable data even when it does not need to be observable.

const obj = observable({
start: 0,
end: 0,
get expensiveCalculation() {
let result = this.start + this.end;
// ... do expensive calculation
return result;
}
});
obj.start = 1;
obj.end = 1;

Let’s assume that in the example above, a computed property is generated to be used in a very expensive calculation called expensiveCalculation as an observable. This property, every time the start or the end property is changed, performs a complex operation, and it must be updated automatically. In such case, you as the developer must consider whether expensiveCalculation is used often enough to be updated every time. If not, it may be more efficient to call the expensiveCalculation separately when it is necessary.

While it may be obvious, some still forgo such obvious truths and blindly rely on the convenience of a reactivity system. I also have similar experiences, and had to modify the program afterward.

When you’re working on a project, keep in mind that there is no convenience without a cost.

🎀 Closing Remarks

Performance is incredibly valuable to an application that handles massive data like TOAST UI Grid. There are certainly many ways to address the performance issues, but we considered improving the reactivity system’s performance as a main topic. Moreover, we experimented with the three optimization methods in TOAST UI Grid to study the effects. These solutions are not restricted to the Grid, and can be used to help numerous people working with improving the reactivity system. I sincerely hope that this article can be of help to anyone who wish to improve their reactivity system or to understand a reactivity system better.

There were myriad of additional features and solutions to performance issues even after the v4 major update of TOAST UI Grid. It is my genuine hope that more people use TOAST UI Grid as we strive to make continual improvements. If you have any issues or comments, please let us know at github issue.

Finally, I would like to take this moment to thank every member of my team who struggled and worked with me to build a better application. 😎

--

--