The concurrency paradox of React

When an Reactapplication logic becomes complex, the time spent on component rendering will increase significantly. If the time from component rendering to view rendering takes too long, users will perceive the page to be stuck.

To solve this problem, there are two methods:

  1. Let the component render process change from synchronous to asynchronous so that renderthe process page will not get stuck. This is how concurrent updates work
  2. Reduce renderthe number of components required, which is often referred to as Reactperformance optimization

Usually, we will take the above different methods for different types of components. For example, for the following input box with time-consuming logic, method 1 is more suitable (because concurrent updates can reduce lags during input):

function  ExpensiveInput ( {onChange, value} ) {
   // Time-consuming operations 
  const cur = performance. now ();
   while (performance. now () - cur < 20 ) {}

  return  < input  onChange = {onChange}  value = {value}/ > ;
}

So, can these two methods be taken into account at the entire application level? The answer is – not really.

This is because, for complex applications, concurrent updates are usually at odds with performance optimization. That’s what this article is going to talk about – the concurrency paradox.

Welcome to join the human high-quality front-end exchange group and lead flying

Let’s start with performance optimization

For a component, if you want it to remain unchanged when necessary render, the basic condition that needs to be met is: propsthe reference remains unchanged.

ChildFor example, the component dependency in the following code fn propsis fninline form, so the reference will change every time Appthe component renderis used, which is not conducive to Childperformance optimization:

function  App () {
   return  < Child  fn = {() => {/* xxx */}}/> 
}

For Childperformance optimization, it can be fnextracted:

const  fn = () => { /* xxx */ }

function  App () {
   return  < Child  fn = {fn}/ > 
}

When fnrelying on something propsor state, we need to use useCallback:

function  App ( {a} ) {
   const fn = useCallback ( () => a + 1 , [a]);
   return  < Child  fn = {fn}/ > 
}

Similarly, other types of variables need to be used useMemo.

That is to say, when it comes to performance optimization, Reactthe code logic will become complicated (reference changes need to be considered).

When the application becomes more complex, it will face more problems, such as:

  • complex useEffectlogic
  • How status is shared

These problems will overlap with performance optimization issues, eventually leading to applications that are not only complex in logic but also have poor performance.

Solutions to performance optimization

Fortunately, these problems have a common solution-state management.

We talked above that for performance optimization, the key issue is – keeping propsthe reference unchanged.

In native React, if adepends bbdepends c. Then, aafter changes, we need to maintain the stability of references through various methods (such as useCallbackuseMemo) .bc

Doing this itself (keeping the reference unchanged) is an additional mental load for the developer. So, how does state management solve this problem?

The answer is: the state management library manages all original and derived states by itself.

for example:

  • In Recoil, the base state type is called Atom, and other derived states are based on Atomcombinations
  • In Zustand, the basic states are createinstances created by methods
  • In Redux, a global state is maintained, and the states that need to be used selectorare extracted from it.

These state management solutions will maintain all basic states and derived states themselves. When developers import state from a state management library, they can keep propsreferences unchanged as much as possible.

For example, the following example uses Zustandthe above code to modify it. Since state aand dependencies aare fnmanaged Zustandby fn:

const useStore = create ( set => ({
   a : 0 ,
   fn : () =>  set ( state => ({ a : state. a + 1 })),
}))


function  App () {
   const fn = useStore ( state => state. fn )
   return  < Child  fn = {fn}/ > 
}

Problem with concurrent updates

Now we know that the general solution for performance optimization is to maintain a set of logically self-consistent external states through a state management library ( the external state here is Reacta state that is different from itself), and keep the references unchanged.

However, this set of external states will eventually be converted into Reactinternal states (and then the changes in the internal states drive view updates), so there is a problem of state synchronization timing. That is: when to synchronize external state with internal state?

Before concurrent updates React, this wasn’t a problem. Because updates are synchronized and will not be interrupted. Therefore, the same external state can remain unchanged throughout the update process.

For example, in the following code, since Listthe component renderprocess will not be interrupted, it listis stable during the traversal process:

function  List () {
   const list = useStore ( state => state. list )
   return (
     < ul > 
      {list.map(item => < Item  key = {item.id}  data = {item}/ > }
     </ ul >
  )
}

However, for concurrent updates enabled React, the update process may be interrupted, and different Itemcomponents may be in different macro tasks before and after the interruption render, and the information passed to them data propsmay not be the same. This leads to the same update and the same state (in the example list) being inconsistent.

This situation is called tearing(view tearing).

It can be found that the cause is – there is a problem with the synchronization timing between tearingthe external state (the state maintained by the state management library) and the internal state.React

This problem Reactis difficult to solve currently. The next best thing, in order to allow these state libraries to be used normally, Reacta special one was created—— hookuseSyncExternalStoreIt is used to execute updates triggered by the state management library in a synchronous manner, so that there will be no synchronization timing issues.

Since it is executed in a synchronous manner, it must not be updated concurrently~~~

Summarize

In fact, any library that maintains an external state (such as an animation library) involves state synchronization issues and is likely to be incompatible with concurrent updates.

So, which of the following options would you prefer:

  1. No careconcurrent updates. ReactHow you used it before can be used now.
  2. According to the project situation, balance the demands of concurrent updates and performance optimization