React18 has entered the RC (release candidate) phase, only one step away from the official version.

Today, instead of talking about new features, we’ll talk about a detail that makes v18 better than the old version: v18 may have fewer component renders.

Where does the state come from?

In the following components.

1
2
3
4
function App() {
  const [num, update] = useState(0);
  // ...省略
}

App component render executes useState after rendering, which returns the latest value of num.

That is, the component must be rendered in order to know the latest state. Why is this the case?

Consider the following code that triggers an update.

1
2
3
4
5
6
const [num, update] = useState(0);
const onClick = () => {
  update(100);
  update(num => num + 1);
  update(num => num * 3);
}

The onClick execution triggers the update, which leads to the App component render, and then the useState execution.

Inside useState, the following process is followed to calculate num.

  1. update(100) changes num to 100
  2. update(num => num + 1) changes num to 100 + 1 = 101
  3. update(num => num * 3) will change num to 101 * 3 = 303

That is, when the App component renders, num is 303.

So, the state calculation needs to collect the triggered updates first and then calculate them uniformly in useState.

For the above example, naming the updates as u0 to u2 respectively, the calculation formula for the state is

1
baseState -> u0 -> u1 -> u2 = newState

Concurrent brings changes

Concurrent brings the concept of priority to React, which is reflected in the state calculation, where updates have different priorities depending on the scenario that triggered them (for example, updates triggered in onClick callbacks have higher priority than updates triggered in useEffect callbacks).

The difference in the computed state is that if an update has a low priority, it will be skipped.

Assuming the low priority of u1 in the above example, the formula for calculating the num state when the App component is rendered is

1
2
// 其中u1因为优先级低,被跳过
baseState -> u0 -> u2 = newState

That is.

  1. update(100) changes num to 100
  2. update(num => num * 3) changes num to 100 * 3 = 300

Obviously this result is not correct.

Therefore, the logic of React to compute state in concurrent cases will be more complex. Specifically, it may include multiple rounds of computation.

When computing state, if an update is skipped, the next computation will continue to compute from the skipped update backwards.

For example, in the above example, u1 is skipped. When u1 is skipped, num is 100, and the state 100, and all updates after u1, are saved for the next calculation.

In the example that is, u1 and u2 are saved.

The next update is as follows.

  1. initial state is 100, update(num => num + 1) changes num to 100 + 1 = 101
  2. update(num => num * 3) changes num to 101 * 3 = 303

As you can see, the final result 303 is the same as the synchronous React, except that it needs to be rendered twice.

Synchronous React render once, result is 303.

Concurrent React render twice, the result is 300 (intermediate state) and 303 (final state) respectively.

The difference between old and new Concurrent

From the above example we see that the number of times a component renders is affected by how many updates are skipped and may actually be rendered not just twice, but multiple times.

In the old version of Concurrent React, what indicates priority is a timestamp called expirationTime. The algorithm for comparing whether updates should be skipped is as follows.

1
2
3
4
5
6
// 更新优先级是否小于render的优先级
if (updateExpirationTime < renderExpirationTime) {
  // ...被跳过
} else {
  // ...不跳过
}

In this logic, as long as the priority is low, it will be skipped, meaning one more render.

In the new version of concurrent React, priority is stored in a 31-bit binary number.

As an example.

1
2
3
const renderLanes = 0b0101;
u1.lane =           0b0001;
u2.lane =           0b0010;

where renderLanes is the priority specified for this update.

The function to compare priorities is

1
2
3
function isSubsetOfLanes(set, subset) {
  return (set & subset) === subset;
}

Among them.

1
2
3
4
5
// true
isSubsetOfLanes(renderLanes, u1.lane)

// false
isSubsetOfLanes(renderLanes, u2.lane)

u1.lane is included in renderLanes, which means this update has sufficient priority.

u2.lane is not contained in renderLanes, which means this update does not have enough priority and is skipped.

But the lane of the skipped update (u2 in the example) will be reset to 0, i.e.

1
u2.lane = 0b0000;

Obviously any lanes contain zeros.

1
2
// true
isSubsetOfLanes(renderLanes, 0)

So this update will definitely be processed the next time. In other words, in new versions of concurrent React, there will be at most 2 duplicate renders due to priority reasons being skipped.

Last

Compared with the old concurrent React, the new concurrent React will have more advantages in the number of renders.

Reflecting on the user’s senses, the user will see less intermediate state that has not been fully computed.