Understanding The React Source Code - UI Updating (Transaction) VI

To some extent, the sophisticated and efficient UI updating is what makes React React. But before we dive into the well-known mechanisms (virtual DOM and diffing algorithm) that empower the UI updating, we need to understand Transaction which transfers the control from the high-level API setState() to those underlying processing logic.

Files used in this article:

renderers/shared/utils/Transaction.js: defines the core Transaction class

renderers/shared/stack/reconciler/ReactDefaultBatchingStrategy.js: defines ReactDefaultBatchingStrategyTransaction and its API wrapper ReactDefaultBatchingStrategy

renderers/shared/stack/reconciler/ReactUpdates.js: defines the enqueueUpdate() that uses ReactDefaultBatchingStrategy

Unlike the previous posts that start from everyday APIs and move down the call stack. This post will take a bottom up approach.

So firstly, we look at the

Transaction the core class

The only de facto “public” method of this class is perform that also offers its core functionality:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
...
/**
...
*
* @param {function} method Member of scope to call.
* @param {Object} scope Scope to invoke from.
* @param {Object?=} a Argument to pass to the method.
* @param {Object?=} b Argument to pass to the method.
* @param {Object?=} c Argument to pass to the method.
* @param {Object?=} d Argument to pass to the method.
* @param {Object?=} e Argument to pass to the method.
* @param {Object?=} f Argument to pass to the method.
*
* @return {*} Return value from `method`.
*/
perform: function<
A,
B,
C,
D,
E,
F,
G,
T: (a: A, b: B, c: C, d: D, e: E, f: F) => G,
>(method: T, scope: any, a: A, b: B, c: C, d: D, e: E, f: F): G {
/* eslint-enable space-before-function-paren */
...
var errorThrown;
var ret;
try {
this._isInTransaction = true;
...
// one of these calls threw.
errorThrown = true;
this.initializeAll(0);
ret = method.call(scope, a, b, c, d, e, f);
errorThrown = false;
} finally {
try {
if (errorThrown) {
...
try {
this.closeAll(0);
} catch (err) {}
} else {
...
this.closeAll(0);
}
} finally {
this._isInTransaction = false;
}
}
return ret;
},
...

TransactionImpl@renderers/shared/utils/Transaction.js

Besides the invocation of the callback method passed to it as the first argument, perform() simply 1) invokes initializeAll() before the callback and 2) closeAll() after.

Here the errorThrown is used to indicate an exception occurred within method.call(), in which case the logic jump directly to finally block before errorThrown can get a chance to be set to false.

Next we look at the implementation of the two methods that are invoked before and after perform(),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
...
initializeAll: function(startIndex: number): void {
var transactionWrappers = this.transactionWrappers;
for (var i = startIndex; i < transactionWrappers.length; i++) {
var wrapper = transactionWrappers[i];
try {
...
this.wrapperInitData[i] = OBSERVED_ERROR;
this.wrapperInitData[i] = wrapper.initialize
? wrapper.initialize.call(this)
: null;
} finally {
if (this.wrapperInitData[i] === OBSERVED_ERROR) {
try {
this.initializeAll(i + 1);
} catch (err) {}
}
}
}
},

...
closeAll: function(startIndex: number): void {
...// scr: sanity check
var transactionWrappers = this.transactionWrappers;
for (var i = startIndex; i < transactionWrappers.length; i++) {
var wrapper = transactionWrappers[i];
var initData = this.wrapperInitData[i];
var errorThrown;
try {
errorThrown = true;
if (initData !== OBSERVED_ERROR && wrapper.close) {
wrapper.close.call(this, initData);
}
errorThrown = false;
} finally {
if (errorThrown) {
try {
this.closeAll(i + 1);
} catch (e) {}
}
}
}
this.wrapperInitData.length = 0;
},
};

export type Transaction = typeof TransactionImpl;

TransactionImpl@renderers/shared/utils/Transaction.js

These two methods simply iterate this.transactionWrappers and call their initialize() and close() respectively.

The this.transactionWrappers is initialized in the de fecto constructor of Transaction with this.getTransactionWrappers():

1
2
3
4
5
6
7
8
9
10
11
12
13
...
reinitializeTransaction: function(): void {
this.transactionWrappers = this.getTransactionWrappers();
if (this.wrapperInitData) {
this.wrapperInitData.length = 0;
} else {
this.wrapperInitData = [];
}
this._isInTransaction = false;
},
...

TransactionImpl@renderers/shared/utils/Transaction.js

We will see what exactly are those this.transactionWrappers very soon.

The exception handling detail here is a bit interesting. Take initializeAll() as an instance. In the case that an exception occurs within initialize(), a finally block (instead of a catch) processes the initialize() of the rest of this.transactionWrappers (i.e., from i + 1 to transactionWrappers.length-1). Then the exception interrupts the for loop and the entire initializeAll() logic and processes all the way to the finally block within perform(), the initializeAll()’s caller, which effectively skips

1
ret = method.call(scope, a, b, c, d, e, f);

in the case of a exceptional initialization. At last, closeAll() is invoked within the same finally block to finalize the transaction.

Now we know what is a Transaction in its essence, but what is it used for? In order to answer this question, we take a Transaction instantiation as an example that is the transactional entry point of UI updating.

ReactDefaultBatchingStrategyTransaction

Firstly ReactDefaultBatchingStrategyTransaction is a subclass of Transaction that implements getTransactionWrappers():

1
2
3
4
5
6
7
8
9
...
Object.assign(ReactDefaultBatchingStrategyTransaction.prototype, Transaction, {
getTransactionWrappers: function() {
return TRANSACTION_WRAPPERS;
},
});
...

ReactDefaultBatchingStrategyTransaction@renderers/shared/stack/reconciler/ReactDefaultBatchingStrategy.js

Next, TRANSACTION_WRAPPERS are the source of this.transactionWrappers that offers the pre (initialize()) and post (close()) functions for perform() used in the last section.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
var RESET_BATCHED_UPDATES = {
initialize: emptyFunction,
close: function() {
ReactDefaultBatchingStrategy.isBatchingUpdates = false;
},
}; // scr: -----------------------------------------------------> 2)

var FLUSH_BATCHED_UPDATES = {
initialize: emptyFunction,
close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates),
}; // scr: -----------------------------------------------------> 2)

var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES]; // scr: -------------------------------> 2)

function ReactDefaultBatchingStrategyTransaction() {
this.reinitializeTransaction();
} // scr: ------------------------------------------------------> 1)
...
// scr: ------------------------------------------------------> 3)
var transaction = new ReactDefaultBatchingStrategyTransaction();
...

ReactDefaultBatchingStrategyTransaction@renderers/shared/stack/reconciler/ReactDefaultBatchingStrategy.js

1) in the constructor of ReactDefaultBatchingStrategyTransaction the super class Transaction’s constructor gets called, which initializes this.transactionWrappers with FLUSH_BATCHED_UPDATES defined in 2)

2) defines the two wrapper and their respective initialize() and close(), which is used in the Transaction.initializeAll() and Transaction.closeAll() in the loops iterating FLUSH_BATCHED_UPDATES

3) defines ReactDefaultBatchingStrategyTransaction as a singleton.

Last we look at the public API offered by ReactDefaultBatchingStrategy that can be called from the outside world

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var ReactDefaultBatchingStrategy = {
isBatchingUpdates: false,

batchedUpdates: function(callback, a, b, c, d, e) {
var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;

ReactDefaultBatchingStrategy.isBatchingUpdates = true;

// The code is written this way to avoid extra allocations
if (alreadyBatchingUpdates) { // scr: --------> not applied here
return callback(a, b, c, d, e);
} else {
return transaction.perform(callback, null, a, b, c, d, e);
}
},
};

ReactDefaultBatchingStrategy@renderers/shared/stack/reconciler/ReactDefaultBatchingStrategy.js

ReactDefaultBatchingStrategy is injected {post two *5} to ReactUpdates as batchingStrategy. And the ReactDefaultBatchingStrategy.batchedUpdates() is used by ReactUpdates.enqueueUpdate(), the underlying method of the UI updating entry point setState().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function enqueueUpdate(component) {
ensureInjected();

if (!batchingStrategy.isBatchingUpdates) { // scr: ----------> {a}
batchingStrategy.batchedUpdates(enqueueUpdate, component);
return;
}

// scr: -----------------------------------------------------> {b}
dirtyComponents.push(component);
if (component._updateBatchNumber == null) {
// scr: this field is used for sanity check later
component._updateBatchNumber = updateBatchNumber + 1;
}
}

ReactUpdates@renderers/shared/stack/reconciler/ReactUpdates.js

Here is a similar recursion trick as we saw in last post.

1) When the method is entered the first time, ReactDefaultBatchingStrategy.isBatchingUpdates is false, which triggers branch {a} that leads to ReactDefaultBatchingStrategy.batchedUpdates();

2) batchedUpdates() sets ReactDefaultBatchingStrategy.isBatchingUpdates to true, and initializes a transaction;

3) the callback argument of batchedUpdates is enqueueUpdate() itself, so enqueueUpdate will be entered again with transaction.perform() straight away. Note that the pre-methods (initialize()) of both wrappers are emptyFunction so nothing happens between the two times invocation of enqueueUpdate();

4) when enqueueUpdate() is entered the second time (within the context of the transaction just initialized), branch {b} is executed;

1
2
3
...
dirtyComponents.push(component);
...

5) after enqueueUpdate() returned post-method (close()) of FLUSH_BATCHED_UPDATES is called; This is the workhorse method that processes all the dirtyComponents marked in the previous step(s)

*8 we will come back to this FLUSH_BATCHED_UPDATES.close() and ReactUpdates.flushBatchedUpdates() in the next post

6) last, post-method (close()) of RESET_BATCHED_UPDATES is called, which sets ReactDefaultBatchingStrategy.isBatchingUpdates back to false and completes the circle.

It is important to note that any successive calls of enqueueUpdate() between 3) and 6) are supposed to be executed in the context of ReactDefaultBatchingStrategy.isBatchingUpdates:false, meaning, branch {b} will be taken in such case. So it’s like

1
2
3
4
5
->dirtyComponents.push(component);
->dirtyComponents.push(component);
->dirtyComponents.push(component);
...
----->ReactUpdates.flushBatchedUpdates

Wrap-up

That's it. Did I make a serious mistake? or miss out on anything important? Or you simply like the read. Link me on -- I'd be chuffed to hear your feedback.