Concurrency Paradox of React

May 5, 2023 1266hotness 0likes 0comments

when a React application logic becomes complex, the time taken by the component render increases significantly. If it takes too long to render from the component render to the view, the user will feel the page stutter.

there are two ways to solve this problem:

  1. change the process of the component render from synchronous to asynchronous, so that the render process page does not get stuck. This is the principle of concurrent updates

  2. reduce the number of components requiring render , which is often referred to as React performance optimization

usually, we take the above different approach for different types of components. For example, for input boxes with time-consuming logic such as the following, method 1 is more appropriate (because concurrent updates reduce stutters at input):

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

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

so, can you take into account both of these two ways at the whole application level? The answer is-- not really.

this is because, for complex applications, concurrent updates are often contrary to performance optimization. That's what this article is about-the concurrency paradox.

start with performance optimization

for a component, if you want it not to be render , the basic condition that needs to be achieved is that the reference of props remains unchanged.

for example, in the following code, Child components depend on fn props . Because fn is inline, references change every time App > component render , which is not conducive to Child performance optimization:

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

for Child performance optimization, fn can be extracted:

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

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

when fn depends on some props or state , we need to use useCallback :

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

Similarly, useMemo is required for other types of variables.

that is, when it comes to performance optimization, the code logic of React becomes complex (reference changes need to be considered).

when the application is more complex, it will face more problems, such as:

  • complex useEffect logic

  • how states are shared

these problems will superimpose with performance optimization problems, resulting in not only complex logic but also poor performance of the application.

solution to performance optimization

Fortunately, there is a common solution to these problems-state management.

as we talked about above, the key issue for performance optimization is to keep the props reference unchanged.

in native React , if a depends on b , b depends on c . So, when a changes, we need to keep b , c references stable through various methods (such as useCallback , useMemo ).

doing this in itself (keeping the reference unchanged) is an additional mental burden for developers. So how does state management solve this problem?

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

for example:

  • in Recoil , the base state type is called Atom , and other derived states are based on Atom combinations

    .

  • in Zustand , the base state is all instances created by the create method

  • in Redux , a global state is maintained, and the needed states are extracted by selector

    .

these state management schemes maintain all base and derived states on their own. When developers import state from the state management library, they can keep the props reference as much as possible.

for example, the following example modifies the above code with Zustand . Since the state a and fn that depend on a are managed by Zustand , references to fn remain unchanged:

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}/>
}

problems with concurrent updates

now we know that the general solution to performance optimization is to maintain a logically consistent set of external states through the state management library (the external here is different from the React itself) and keep the reference unchanged.

however, this set of external states must eventually be transformed into the internal state of React (and then the view update is driven by changes in the internal state), so there is a problem of the timing of state synchronization. That is, when will the external state be synchronized with the internal state?

this is not a problem in React before concurrent updates. Because updates are synchronized and will not be interrupted. So for the same external state, it can remain the same throughout the update process.

for example, in the following code, because the render process of the List component is not interrupted, list is stable during traversal:

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

however, for React with concurrent updates enabled, the update process may be interrupted, and different Item components may be render in different macro tasks before and after the interrupt, and the data props may not be the same. This results in an inconsistency between the same update and the same state ( list in the example).

this situation is called tearing (view tearing).

it can be found that tearing is caused by a problem with the timing of synchronization between the external state (state management library maintenance state) and the React internal state.

this problem is difficult to solve in the current React . Second, in order for these state libraries to work properly, React has a hook -- useSyncExternalStore . Used to synchronize updates triggered by the state management library, so that there is no question of timing.

since it is executed synchronously, it must be impossible to update concurrently ~

.

Summary

in fact, any library that involves maintaining an external state (such as an animation library) involves state synchronization and may not be compatible with concurrent updates.

so, which of the following choices would you prefer:

  1. No care concurrent updates. How to use React in the past, but how to use it now

  2. balance concurrent updates and performance optimization based on the project

InterServer Web Hosting and VPS

Aaron

Hello, my name is Aaron and I am a freelance front-end developer

Comments