Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POC: Transition compatibility hooks #44151

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

devknoll
Copy link

@devknoll devknoll commented Oct 18, 2024

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 19 initialValue? 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:

const [isPending, startTransition] = useTransition();
const [state, dispatch] = useReducer((s, a) => [...s, a], []);

// ...

startTransition(() => {
  dispatch('foo');
});
dispatch('bar');

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 either unstable_useStateCompat or unstable_useReducerCompat. That means in order to reproduce the React 18 behavior, you need to write this instead:

const [isPending, startTransition] = unstable_useTransitionCompat();
const [state, dispatch] = unstable_useReducerCompat((s, a) => [...s, a], []);

// ...

startTransition(() => {
  dispatch('foo');
});
dispatch('bar');

Once ready to adopt React 18, you would first migrate unstable_useTransitionCompat to useTransition, and then unstable_useStateCompat/unstable_useReducerCompat alternatives.

unstable_useStateCompat

Just like useState, but for use with unstable_useTransitionCompat. If you're not updating the value in a transition, you should just use useState.

unstable_useReducerCompat

Just like useReducer, but for use with unstable_useTransitionCompat. If you're not updating the value in a transition, you should just use useReducer.

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:

  1. Whenever a blocking update is queued (i.e. outside of startTransition or useDeferredValue), all previous batches (even non-blocking) must be applied before the blocking update is. This is implemented by blockingBatchCallback

  2. 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 uses setTimeout to apply the changes in a future macrotask, so blocking updates have a chance to be painted first.

  3. 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 actual startTransition.

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.

@devknoll devknoll force-pushed the x-add-transition-compat-hooks branch 3 times, most recently from 286fb4b to 4402ae2 Compare October 22, 2024 18:40
@devknoll devknoll force-pushed the x-add-transition-compat-hooks branch from 4402ae2 to 34faf78 Compare October 22, 2024 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant