Concurrent React for Library Maintainers #70
Replies: 4 comments 4 replies
-
The reason why snapshots may make it fast is we can skip some renders if some parts are not changed, right? Whereas with |
Beta Was this translation helpful? Give feedback.
-
So, de-opting happens in level1 and level2. |
Beta Was this translation helpful? Give feedback.
-
Observed something interesting that might be worth sharing here. We are using the Radix UI Popover component, which uses an interesting approach for animation under the hood. Here's a demo of it running with React 17: https://codesandbox.io/s/autumn-butterfly-p1r8f. After looking into this, their docs said (similar to what React Transition Group does):
Fading out before unmounting a component is indeed a very common use case. And Radix's implementation seems to assume that rendering is synchronous. So after the CSS animation ends, it sets a state to unmount the element and it supposed to be done immediately (but it didn't). This is related to the tearing problem mentioned above I believe. The fix (for our specific use case) is very simple, by adding |
Beta Was this translation helpful? Give feedback.
-
One of my goals is to support level 3 (even partially) with a mutable store. jotai is atomic state manager, but without the notion of atoms, the experimental feature works like the following: const StoreContext = createContext();
const StoreProvider = ({ initialValue, children }) => {
const [version, setVersion] = useState();
const storeRef = useRef({
value: initialValue, // mutable value
versionMap: new WeakMap(), // versioned value map
listeners: new Set(),
setVersion,
readValue: (version) => {
if (!storeRef.current.versionMap.has(version)) {
// first read with this version, create a snapshot
storeRef.current.versionMap.set(
version,
version.parent
? storeRef.current.versionMap.get(nextVersion.parent) // read from parent version
: storeRef.current.value // or from committed value
);
}
return storeRef.current.versionMap.get(version);
},
writeValue: (version, value) => {
storeRef.current.versionMap.set(version, value);
},
commitValue: (version, value) => {
storeRef.current.value = value;
// delete parent version for GC
delete version.parent;
});
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
}
const useStore = () => {
const store = useContext(StoreContext);
const [[version, value], dispatch] = useReducer(
(prev, nextVersion) => {
const nextValue = store.readValue(nextVersion);
return [nextVersion, nextValue];
},
[undefined, store.value] // no branching on mount (this can be unsafe?)
);
useEffect(() => {
// subscribe to store
store.listeners.add(dispatch)
return () => store.listeners.delete(dispatch)
}, [store]);
useEffect(() => {
store.commitValue(version, value);
});
const setValue = useCallback((action) => {
store.setVersion((parent) => {
const nextVersion = { parent }; // create a new object
const previousValue = store.readValue(nextVersion);
const nextValue = typeof action === 'function' ? action(previousValue) : action;
store.writeValue(nextVersion, nextValue);
store.listeners.forEach((dispatch) => dispatch(nextVersion)); // this causes render warnings
})
}, [store])
return [value, setValue]; // like useState
}; Again, this is simplified without atoms. (Hm, it can still be more simplified because there are no derived values.) |
Beta Was this translation helpful? Give feedback.
-
Overview
tl;dr: If your library only uses React props, state or context, your library likely already supports Concurrent React. This includes libraries that provide components or custom Hooks. For the small set of libraries that depend on external mutable state, we’re providing guidance on how to support external data stores in Concurrent React.
React 18 adds a number of new features that use concurrent rendering under the hood. These features are opt-in for application users so that concurrent rendering can be gradually added inside of smaller portions of the app that use the new features.
However, application users depend on using libraries throughout their app. So in order for application users to opt-into concurrent rendering, the libraries they use will need to support concurrent rendering. This means that libraries will need to support concurrent rendering in order for applications to use concurrent features in React 18.
Fortunately, we expect this to only impact a small number of libraries that depend on external mutable state. In this post we’ll explain why we think that’s the case, the problems those libraries may run into, and the different steps to supporting concurrent rendering.
What kinds of libraries are affected?
We expect that most libraries in the ecosystem will not be impacted by concurrent rendering.
For example, components and custom Hooks which don’t access external mutable data while rendering and only pass information using React props, state or context, shouldn’t have issues because React handles those concurrently natively.
The small number of libraries that may need to be updated are generally libraries which deal with data fetching, state management, or styling. That’s because these libraries store their own state outside of React. With concurrent rendering, those data stores can be updated in the middle of rendering, without React knowing about it.
This problem is known as “tearing” and in certain situations can result in inconsistent UIs or even errors.
See What is tearing for a deep-dive on the tearing issue.
Why does this only impact libraries using external state?
Libraries that only use React state do not have to deal with tearing issues because React state is integrated directly into concurrent rendering to prevent tearing.
When you change React state, React doesn’t immediately change the state. Instead, React queues the update and schedules a render. When React starts to render, it will then look at the entire queue of updates, and use different heuristics and algorithms to determine the next update to work on. This process has safeguards and semantics in place to ensure that rendering is always consistent.
For example, let’s say React is in the middle of rendering a concurrent update and yields to other work. In that other work, let’s say that timer updates some other state that’s not the one that’s rendering. When that happens, React will queue the update for later. Once React starts rendering, it will see that the update was unrelated, and finish the current render. Once that render is done, if no other updates are scheduled, React will work on the second update that came in.
As another example, let’s take the same scenario but this time let’s say that the timer updates the same state that is already being rendered. React will still queue it the same as before, but now when React starts rendering it will see that there is a newer update to the same state. So React can throw out what it was already rendering (which is out of date), and start working on the new update. This is a performance and UX win because we don't waste time working on outdate updates and we don't flash outdated state to users.
React works to ensure this type of consistency for all combinations of state updates. This management of state inside of React to maintain a consistent tree while still being able to do complex concurrent and asynchronous behavior is the key feature of React. At its core, React is essentially a library for processing a queue of state updates to produce consistent UIs.
So when a library uses external state, it loses access to all of this effort React put into making consistency guarantees for React state.
With external state, instead of scheduling updates to a queue that can be processed in the correct order, the state can be mutated directly in the middle of rendering. So to support an external store, you need some way to either 1) tell React that the store updated during render so React can re-render again 2) force React to interrupt and re-render when the external state changes or 3) implement some other solution that allows React to render without state changing in the middle of renders.
These solutions are mapped the three different levels of concurrent rendering support.
What are the different levels of Concurrent Rendering support?
It can sometimes be confusing to talk about concurrent rendering support in a library because there are different tradeoffs you can take with different behaviors. To make it easier, we propose to talk about three different levels of support:
The bare minimum support is to just allow the UI to temporarily tear. With this level of support, application developers can use the library with concurrent features, but may temporarily see inconsistent UIs in their app.
An example of this level is using the
useSubscription
hook which may tear during initial render but will trigger a synchronous update afterwards to “fix” the tear. If the tear causes an error during render, React will automatically retry the render synchronously from the closest error boundary. This will automatically “fix” the error, but it loses all benefits of concurrent rendering. With this option, in the worst case scenario, users may see a flash of inconsistent values on screen, then a pause for a sync re-render to show a consistent UI.✅ Level 2: Make it right
The second level, which may currently be the best case for many libraries, is to not tear at all but occasionally take longer to render. With just this level of support, application developers can use the library with concurrent features without any visual inconsistencies, but it is not as optimized as it could be.
An example of this is the
proposeduseMutableSource
useSyncExternalStore
hook which will detect changes in external state during render and re-start rendering before showing the inconsistent UI to the user. The worst case here is that rendering takes a long time, but the user will always see a consistent UI.🚀 Level 3: Make it fast
The ideal level of support is to always show a consistent UI without de-opting .With this level of support, application developers get the full benefits of concurrent rendering when using concurrent features with libraries. Most libraries that only depend on React state have already met this level.
The example of this is to use React state, which does not tear or de-opt. Another example is to use a mutable store with immutable snapshots which do not change during render, but this strategy is still under research.
Note: libraries that only depend on React state should already be level 3. Level 1 and 2 are for a small number of libraries that depend on external mutable state or on React state in atypical ways.
Can I use concurrent features in my library?
For React 18, we’re asking libraries to meet one of the first three levels of support so that application developers can use concurrent features to opt-into concurrent rendering with support in the libraries they use. This will allow application users to gradually adopt concurrent features without libraries opting them into concurrent rendering.
These levels do not include actually using concurrent features in libraries. Currently we are discouraging libraries from using concurrent features in libraries because that would opt-in applications to concurrent rendering in ways that application developers may not be expecting. After the community has had had time to adopt the features themselves and support concurrent rendering more broadly, we’ll encourage libraries to add concurrent features.
Where should I start?
The next step is for library maintainers is test concurrent rendering with their library, document the issues they find, and implement one of the levels of support. We’re here to discuss issues, solutions, and provide support so please let us know how it goes!
Beta Was this translation helpful? Give feedback.
All reactions