Since experimental useMutableSource
API was added, we’ve made changes to our overall concurrent rendering model that have led us to reconsider its design. Members of this Working Group have also reported flaws with the existing API contract that make it difficult for library maintainers to adopt useMutableSource
in their implementations.
After additional research and discussions, here are our proposed changes to the API, which we intend to complete for React 18.
(For background, refer to the RFC for useMutableSource
: https://github.com/reactjs/rfcs/blob/master/text/0147-use-mutable-source.md)
import {useSyncExternalStore} from 'react'; // We will also publish a backwards compatible shim // It will prefer the native API, when available import {useSyncExternalStore} from 'use-sync-external-store/shim'; // Basic usage. getSnapshot must return a cached/memoized result const state = useSyncExternalStore(store.subscribe, store.getSnapshot); // Selecting a specific field using an inline getSnapshot const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);
getSnapshot
is used to check if the subscribed value has changed since the last time it was rendered, so the result needs to be referentially stable. That means it either needs to be an immutable value like a string or number, or it needs to be a cached/memoized object.
As a convenience, we will provide a version of the API with automatic support for memoizing the result of getSnapshot
:
// Name of API is not final import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/with-selector'; const selection = useSyncExternalStoreWithSelector( store.subscribe, store.getSnapshot, getServerSnapshot, selector, isEqual );Selectors no longer need to be memoized
Inline selectors are a common feature of state libraries, especially those that provide a hook-based API, such as Redux’s useSelector
.
A selector is a function that accepts a state value and “selects” the subset that is needed by the component. It signals that React only needs to re-render if that subset has changed.
Because useMutableSource
does not provide a built-in selector
API, the only way to implement this behavior is to resubscribe to the store every time the inline selector changes. If the selector function is not memoized, this means resubscribing on every new render. This is not only a performance pitfall, it’s one that leaks into user code: the user of a state management library must take extra caution to memoize all of its selectors.
Refer to #84 for a detailed description of this problem.
The new API is designed so that React no longer needs to resubscribe when the getSnapshot
function changes. So you can pass an unmemoized selector without degrading performance.
By default, React will detect changes to a selected value by comparing with Object.is
. Some state libraries rely on comparing values with a custom comparison function, like shallowEqual
. While we don’t necessarily recommend this pattern, it can be implemented in userspace. Since we expect this to be a common feature request, we will publish our own userspace implementation.
Another flaw of the useMutableSource
API is that it can sometimes cause visible parts of the UI to be replaced with a fallback, even when the update is wrapped with startTransition
(the API that is meant to avoid this scenario).
The reason is that startTransition
relies on the ability to maintain multiple versions of a UI simultaneously (“concurrently”): the current UI that’s visible on screen, and a work-in-progress UI that is prepared in the background while data progressively streams in. React can do this with its built-in state APIs — useState
and useReducer
— but not for state that lives outside React, because we only can access a single version of state at a time. (To illustrate with an example, Redux’s store has a getState
method, but it doesn’t have a getBackgroundState
method; we could theoretically implement a contract to support concurrent data stores, but that’s outside the scope of this proposal.)
Our original strategy was to provide partial support for concurrent features: start rendering concurrently, and only deopt back to synchronous when we detect an inconsistency. “Deopt" in this context can mean:
We went through great pains to preserve time-slicing as much as possible (1), even if it meant hiding already-visible UI with a fallback (2).
We’ve since concluded that this trade-off is backwards: Replacing visible content with a fallback is a significant regression in the user experience, especially if it happens unpredictably. By contrast, occasionally disabling time-slicing — while not ideal — has a much less dramatic effect on the end user experience.
Even worse is that useMutableSource
can cause bad fallbacks during state updates that are completely unrelated, such as those triggered by a router, or a regular useState
hook.
As we were brainstorming alternative strategies, a key revelation was that if you can’t rely on startTransition
to always avoid bad fallbacks caused by a store update, then you shouldn’t ever rely on it — you should avoid the fallbacks in some other way. For example, you could do it the same way you would today without Suspense: by waiting for new data to load before triggering the update.
The next key revelation was that we can avoid deopts during updates triggered by React state transitions if updates triggered by external stores are always synchronous. That’s because if updates to stores are synchronous, they are guaranteed to be consistent.
So, in the new design:
startTransition
We think this hits the sweet spot of feature compatibility, ease of adoption, and predictable user experience.
We will be renaming the API to useSyncExternalStore
to reflect its updated behavior.
As a bonus, we can get rid of the source
argument and the corresponding createMutableSource
API.
Our goal is for all subscription-based libraries to migrate their implementations to useSyncExternalStore
.
While it’s possible to implement a concurrent-compatible subscription in userspace, it’s very tricky to get right. Existing store implementations will continue to work as they do in React 17 until or unless a store update is wrapped with startTransition
, at which point concurrency bugs may surface.
To encourage adoption by open source libraries, we will provide a shim that is compatible with older versions of React. The shim will prefer the built-in API when it is available, so that users get the correct implementation regardless of which version they’re running.
We are also considering a heuristic to detect when a userspace store update is wrapped with startTransition
, so we can print advice to the console (in development mode) to use useSyncExternalStore
instead. The heuristic is to count how many separate components are updated within a single startTransition
call. If it’s greater than some arbitrary threshold, say 20, we can infer that it likely contains a subscription.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4