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:
-
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 -
reduce the number of components requiring
render
, which is often referred to asReact
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 calledAtom
, and other derived states are based onAtom
combinations.
-
in
Zustand
, the base state is all instances created by thecreate
method -
in
Redux
, a global state is maintained, and the needed states are extracted byselector
.
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:
-
No care concurrent updates. How to use
React
in the past, but how to use it now -
balance concurrent updates and performance optimization based on the project
Comments