π Explore More Possibilities with React Hooks? ReactUse.com provides you with well-designed custom Hooks to double your React development efficiency!
The New Challenge of Concurrent Rendering
Before React 18, React always rendered synchronously, meaning that once rendering started, the entire process could not be interrupted. But React 18 introduced concurrent rendering, allowing React to pause and resume during the rendering process to handle higher-priority tasks (like user interactions).
Although this mechanism improves the user experience, it also brings a new problem: when React pauses during rendering, the external data source might change, causing different components in the same render to see different data snapshots, leading to a UI tearing phenomenon.
What is Tearing?
Let's first understand the tearing phenomenon through an example:
import { useEffect, useState, startTransition } from 'react';
let externalState = { counter: 0 };
let listeners: any[] = [];
function dispatch(action: { type: any }) {
if (action.type === 'increment') {
externalState = { counter: externalState.counter + 1 };
} else {
throw Error('Unknown action');
}
listeners.forEach((fn) => fn());
}
function subscribe(fn: () => void) {
listeners = [...listeners, fn];
return () => {
listeners = listeners.filter((f) => f !== fn);
};
}
function useExternalData() {
const [state, setState] = useState(externalState);
useEffect(() => {
const handleChange = () => setState(externalState);
const unsubscribe = subscribe(handleChange);
return unsubscribe;
}, []);
return state;
}
setInterval(() => {
dispatch({ type: 'increment' });
}, 50);
export default function App() {
const [show, setShow] = useState(false);
return (
<div className="App">
<button
onClick={() => {
startTransition(() => {
setShow(!show);
});
}}
>
Toggle Content
</button>
{show && (
<>
<SlowComponent />
<SlowComponent />
<SlowComponent />
<SlowComponent />
<SlowComponent />
</>
)}
</div>
);
}
function SlowComponent() {
let now = performance.now();
while (performance.now() - now < 200) {
// Simulate a slow component
}
const state = useExternalData();
return <h3>Counter: {state.counter}</h3>;
}
Running this example, you'll be surprised to find that after clicking the button to show the content, several identical SlowComponent
components display different content at the moment they appear!
This is what is known as "tearing" in React, and it was introduced by the concurrent features of React 18.
Tearing is a term traditionally used in graphics programming to refer to a visual inconsistency.
For example, in video, screen tearing is when you see multiple frames in a single screen, which makes the video look "glitchy". In user interfaces, "tearing" refers to the UI displaying multiple values for the same state. For example, you might show different prices for the same item in a list, or submit a form with the wrong price, or even crash when accessing a stale store value.
Since JavaScript is single-threaded, this problem usually doesn't occur in web development. But in React 18, concurrent rendering makes this problem possible because React yields control during rendering. This means that when using concurrent features like startTransition or Suspense, React can pause to let other work happen. In between these pauses, an update might sneak in, changing the data being used for rendering, which can lead to the UI showing two different values for the same data.
This problem is not specific to React; it's an inevitable consequence of concurrency. If you want to be able to interrupt rendering to respond to user input for a more responsive experience, you need to be resilient to the data being rendered changing and causing the UI to tear.
The Era of Synchronous Rendering
Before React 18, React always rendered synchronously.
Take the following diagram as an example:
In the first image, we can see a component accessing an external store to get its state and rendering it into the component.
In the second image, other components also render this state. Since our rendering process is not interrupted, it proceeds to the second component.
In the third image, we can see all components have been rendered, and the page now presents a unified UI.
If the external store changes, the components we are discussing will start over from the first image.
The Era of Concurrent Rendering
Still using the diagram below as an example:
In the first image, the initial external store is blue, and the first component renders as blue, which is fine.
In the second image, let's say the user clicks a button to change the external store to red. At this point, rendering is interrupted, and React allows the user's action to take effect to improve the user experience.
In the third image, other components continue to render, fetching the changed external store and rendering as red.
In the fourth image, several components are rendered, but due to the concurrent rendering issue, the change in the external store is not detected. To re-render all components, the same data will display different values. This situation is the tearing phenomenon.
Solution: useSyncExternalStore
So how do we solve the tearing problem with external state? This is where the useSyncExternalStore
Hook comes into play.
An example of the useSyncExternalStore
hook is that it will detect changes in the external state during rendering and restart the render before displaying an inconsistent UI to the user. The worst-case scenario here is that the render takes a long time, but the user will always see a consistent UI.
Let's rewrite the above example using useSyncExternalStore
:
import { useState, useSyncExternalStore, useTransition } from 'react';
const counterStore = {
value: 0,
listeners: new Set(),
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
},
getSnapshot() {
return this.value;
},
increment() {
this.value++;
this.listeners.forEach(listener => listener());
}
};
setInterval(() => {
counterStore.increment();
}, 10);
function useCounter(): number {
return useSyncExternalStore(
counterStore.subscribe.bind(counterStore),
counterStore.getSnapshot.bind(counterStore)
);
}
function SlowComponent() {
const count = useCounter();
let now = performance.now();
while (performance.now() - now < 200) {
}
return <div>Counter: {count}</div>;
}
export default function App() {
const [show, setShow] = useState(false);
const [isPending, startTransition] = useTransition();
const toggleSlowComponents = () => {
startTransition(() => {
setShow(!show);
});
};
return (
<div>
<button onClick={toggleSlowComponents} disabled={isPending}>
{isPending ? 'Loading...' : 'Toggle Slow Components'}
</button>
{show && (
<>
<SlowComponent />
<SlowComponent />
<SlowComponent />
<SlowComponent />
<SlowComponent />
</>
)}
</div>
);
}
Summary
While React 18's concurrent rendering features improve application responsiveness and user experience, they also introduce the new challenge of UI tearing. When React pauses during rendering to handle high-priority tasks, changes in external data sources can cause different components in the same render to see inconsistent data snapshots.
Core Points:
Nature of the Problem: The tearing phenomenon is an inevitable consequence of concurrent rendering, not a problem specific to React, but a challenge that all concurrent systems must face.
Solution Strategy: The
useSyncExternalStore
Hook ensures UI consistency by monitoring external state changes during rendering and restarting the render if an inconsistency is detected.-
Best Practices:
- For external state management, prioritize using
useSyncExternalStore
. - Understand the working mechanism of concurrent rendering and use
startTransition
andSuspense
appropriately. - Consider concurrency safety when designing state management solutions.
- For external state management, prioritize using
Performance Trade-off: Although re-rendering might affect performance, ensuring UI consistency is more important than rendering speed. The predictability of the user experience is the primary goal.
By correctly using useSyncExternalStore
, developers can enjoy the performance improvements brought by React 18's concurrent features while avoiding UI tearing issues, building more robust and user-friendly applications.
Top comments (0)