POC: Transition compatibility hooks #44151
Draft
+322
−0
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Proof of concept of compatibility hooks enabling transitions to be used in React 17 applications. There are a few key differences outlined below.
Compatibility Hooks
unstable_useDeferredValueCompat(value)
Implements
React.useDeferredValue
. Note that the React 19initialValue?
argument is not currently supported, as there are open questions as to shimming this behavior in React 18.unstable_useTransitionCompat()
Implements
React.useTransition
, including support for async actions. Note that the semantics for this hook differ slightly from React. Consider the following:It's expected that the value of
state
will be['foo', 'bar']
. That's because conceptually, state updates are queued, and even though the first update was scheduled non-blocking, the update still needs to be applied chronologically before the second update. Unfortunately, this type of queuing is not trivial to implement generally (i.e. for all state) without brittle monkey patching of React.Instead, the compatibility hook works a little differently. Instead of marking all state updates inside of
startTransition
as non-blocking, it marks everything as blocking by default -- even in React 18 and higher. To mark state updates as non-blocking with the compatibility hook, the state hook itself must use eitherunstable_useStateCompat
orunstable_useReducerCompat
. That means in order to reproduce the React 18 behavior, you need to write this instead:Once ready to adopt React 18, you would first migrate
unstable_useTransitionCompat
touseTransition
, and thenunstable_useStateCompat
/unstable_useReducerCompat
alternatives.unstable_useStateCompat
Just like
useState
, but for use withunstable_useTransitionCompat
. If you're not updating the value in a transition, you should just useuseState
.unstable_useReducerCompat
Just like
useReducer
, but for use withunstable_useTransitionCompat
. If you're not updating the value in a transition, you should just useuseReducer
.How it works
The
CompatTransitionManager
maintains batches, queues of updates that should be applied together. The primary question is when the batches should be applied:Whenever a blocking update is queued (i.e. outside of
startTransition
oruseDeferredValue
), all previous batches (even non-blocking) must be applied before the blocking update is. This is implemented byblockingBatchCallback
Whenever a non-blocking update is queued, the update can be applied at some point in the future. This is implemented by
nonBlockingBatchCallback
, which simply usessetTimeout
to apply the changes in a future macrotask, so blocking updates have a chance to be painted first.Running the compatibility hooks in React 18 is a special case, as we want to preserve the same semantics for those upgrading from React 17. This is implemented by
createPassthroughBatchCallback
, which executes only updates batched via the compatibility hooks inside the actualstartTransition
.React has a much more sophisticated strategy than these compatibility hooks. However, without being able to interrupt or reprioritize rendering work, it just doesn't make much sense to be more complex than this. We accept that without incremental rendering, non-blocking rendering will inevitably make the UI less responsive (that's why it was marked as non-blocking in the first place: so it wouldn't make the UI less responsive), and so we just make sure we paint any pending states/loading indicators in a blocking update, and cram as much work as possible into the subsequent update.
Why?
Transitions help make applications more responsive and improve Core Web Vitals like INP, especially on lower end devices like mobile. While the compatibility hooks aren't perfect, they can significantly improve the user experience.
Consider the
Autocomplete
component. Without transitions, running with 20x slowdown, you end up with something like this:Screen.Recording.2024-10-16.at.11.35.42.AM.mov
The UI simply freezes while the large list is being rendered. However, by using
useDeferredValue
, we can achieve this instead:Screen.Recording.2024-10-16.at.11.26.18.AM.mov
Even in React 17 via
unstable_useDeferredValue
we can achieve a significant improvement to INP (~800ms vs 2s). While the UI is unable to quickly respond to subsequent interactions while the dropdown content is rendering (due to the lack of time slicing/incremental rendering prior to React 18), we are still able to show our animation and a loading indicator to the user.(Note: the changes to autocomplete are not implemented in this PR. In reality, we would likely want a browser controlled animation that only starts after e.g. a 400ms delay, so that users on faster devices don't see it).
Ideally, applications would just be able to use React 18 or higher. However, in cases where that's not feasible, they should still ideally use transition and action semantics and APIs.