How does useTransition() work internally in React?

1. What does useTransition() do ?

To know what it does, please go to the official doc on react.dev, it has the best explanation.

Below is how to use it.

function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
...
}
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
...
}

By putting setState() call inside startTransition(), we mark the update as transition, which means it has low priority and leads to two main implications.

  1. now the update is interruptable (demo)
  2. no flickering of Suspense fallbacks (demo)

Let’s figure out how it works by diving into React source code.

2. How does useTransition() work ?

useTransition() is a hook, and after so many episodes in this series, we are so familiar where to find it. Yes, in ReactFiberHooks.js.

2.1 mountTransition()

mountTransition() is used for initial render.

function mountTransition() {
const stateHook = mountStateImpl((false: Thenable<boolean> | boolean));

This is the same internal for mountState(),

meaning we can think as if there is a internal call of useState()

// The `start` method never changes.
const start = startTransition.bind(
null,
currentlyRenderingFiber,
stateHook.queue,
true,
false,
);
const hook = mountWorkInProgressHook();

A basic hook to hold startTransition()

hook.memoizedState = start;
return [false, start];

initial isPending is false

}
function mountTransition() {
const stateHook = mountStateImpl((false: Thenable<boolean> | boolean));

This is the same internal for mountState(),

meaning we can think as if there is a internal call of useState()

// The `start` method never changes.
const start = startTransition.bind(
null,
currentlyRenderingFiber,
stateHook.queue,
true,
false,
);
const hook = mountWorkInProgressHook();

A basic hook to hold startTransition()

hook.memoizedState = start;
return [false, start];

initial isPending is false

}

Above code explains the syntax of const [isPending, startTransition] = useTransition().

We can also see that mountTransition() creates 2 hooks inside:

  1. a state hook that holds isPending:boolean.
  2. another hook that holds the startTransition().

This is important for following section.

2.2 startTransition() triggers 2 state updates - one normal and one under transition.

function startTransition<S>(
fiber: Fiber,
queue: UpdateQueue<S | Thenable<S>, BasicStateAction<S | Thenable<S>>>,
pendingState: S,

true, from the bound function in mountTransition()

finishedState: S,

false, from the bound function in mountTransition()

callback: () => mixed,

the callback that is passed in

options?: StartTransitionOptions,
): void {
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(
higherEventPriority(previousPriority, ContinuousEventPriority),
);
const prevTransition = ReactCurrentBatchConfig.transition;
if (enableAsyncActions) {
// We don't really need to use an optimistic update here, because we
// schedule a second "revert" update below (which we use to suspend the
// transition until the async action scope has finished). But we'll use an
// optimistic update anyway to make it less likely the behavior accidentally
// diverges; for example, both an optimistic update and this one should
// share the same lane.
dispatchOptimisticSetState(fiber, false, queue, pendingState);

optimistic update is some other topic, let's skip it for now

} else {
ReactCurrentBatchConfig.transition = null;

Notice here ReactCurrentBatchConfig.transition is set to null

dispatchSetState(fiber, queue, pendingState);

This is basically the same as setStat(true)

}
const currentTransition = (ReactCurrentBatchConfig.transition =
({}: BatchConfigTransition));

Notice that ReactCurrentBatchConfig.transition is set to Non-null from now on

...
try {
if (enableAsyncActions) {
...
} else {
// Async actions are not enabled.
dispatchSetState(fiber, queue, finishedState);

this is false, meaning isPending is reverted to false

setState(true) is already called, so here it is 2nd call of setState()

but this time is a bit different, because ReactCurrentBatchConfig.transition is not null

callback();

Keep in mind that the callback is run under a non-null ReactCurrentBatchConfig.transition

}
} catch (error) {
...
}
}
function startTransition<S>(
fiber: Fiber,
queue: UpdateQueue<S | Thenable<S>, BasicStateAction<S | Thenable<S>>>,
pendingState: S,

true, from the bound function in mountTransition()

finishedState: S,

false, from the bound function in mountTransition()

callback: () => mixed,

the callback that is passed in

options?: StartTransitionOptions,
): void {
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(
higherEventPriority(previousPriority, ContinuousEventPriority),
);
const prevTransition = ReactCurrentBatchConfig.transition;
if (enableAsyncActions) {
// We don't really need to use an optimistic update here, because we
// schedule a second "revert" update below (which we use to suspend the
// transition until the async action scope has finished). But we'll use an
// optimistic update anyway to make it less likely the behavior accidentally
// diverges; for example, both an optimistic update and this one should
// share the same lane.
dispatchOptimisticSetState(fiber, false, queue, pendingState);

optimistic update is some other topic, let's skip it for now

} else {
ReactCurrentBatchConfig.transition = null;

Notice here ReactCurrentBatchConfig.transition is set to null

dispatchSetState(fiber, queue, pendingState);

This is basically the same as setStat(true)

}
const currentTransition = (ReactCurrentBatchConfig.transition =
({}: BatchConfigTransition));

Notice that ReactCurrentBatchConfig.transition is set to Non-null from now on

...
try {
if (enableAsyncActions) {
...
} else {
// Async actions are not enabled.
dispatchSetState(fiber, queue, finishedState);

this is false, meaning isPending is reverted to false

setState(true) is already called, so here it is 2nd call of setState()

but this time is a bit different, because ReactCurrentBatchConfig.transition is not null

callback();

Keep in mind that the callback is run under a non-null ReactCurrentBatchConfig.transition

}
} catch (error) {
...
}
}

The tricky part here is the 2 calls of setState(), as we know that setState() set a flag on fiber marking it needs to be re-run, and then schdules re-render from root. It is NOT synchronous, so 2 calls of setState() will both be processed.

But these 2 calls have different priority, in internal data structure they have differnt Lanes. For each re-render, the updates of highest lane will be chosen. (To understand this, please refer to What are Lanes in React source code?)

Because the only difference between these 2 calls is ReactCurrentBatchConfig.transition, it must be used somewhere to alter the Lane setting.

2.3 requestUpdateLane() returns Transition Lanes if ReactCurrentBatchConfig.transition is set

requestUpdateLane() is called in setState().

function dispatchSetState<S, A>(

This is internal implementation of setState()

fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
): void {
const lane = requestUpdateLane(fiber);

The lane(priority) for an update is not fixed but dynamically determined.

const update: Update<S, A> = {
lane,
----
revertLane: NoLane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
...
}
function dispatchSetState<S, A>(

This is internal implementation of setState()

fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
): void {
const lane = requestUpdateLane(fiber);

The lane(priority) for an update is not fixed but dynamically determined.

const update: Update<S, A> = {
lane,
----
revertLane: NoLane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
...
}

Now let’s take a look at requestUpdateLane() itself.

export function requestCurrentTransition(): Transition | null {
return ReactCurrentBatchConfig.transition;

So ReactCurrentBatchConfig.transition works like a global flag,

to indicate transition

}
export function requestUpdateLane(fiber: Fiber): Lane {
...
const isTransition = requestCurrentTransition() !== NoTransition;
if (isTransition) {
const actionScopeLane = peekEntangledActionLane();
return actionScopeLane !== NoLane
? // We're inside an async action scope. Reuse the same lane.
actionScopeLane
: // We may or may not be inside an async action scope. If we are, this
// is the first update in that scope. Either way, we need to get a
// fresh transition lane.
requestTransitionLane();

We can see that if isTransition(), it returns a Transition Lane

}
...
}
export function requestCurrentTransition(): Transition | null {
return ReactCurrentBatchConfig.transition;

So ReactCurrentBatchConfig.transition works like a global flag,

to indicate transition

}
export function requestUpdateLane(fiber: Fiber): Lane {
...
const isTransition = requestCurrentTransition() !== NoTransition;
if (isTransition) {
const actionScopeLane = peekEntangledActionLane();
return actionScopeLane !== NoLane
? // We're inside an async action scope. Reuse the same lane.
actionScopeLane
: // We may or may not be inside an async action scope. If we are, this
// is the first update in that scope. Either way, we need to get a
// fresh transition lane.
requestTransitionLane();

We can see that if isTransition(), it returns a Transition Lane

}
...
}

2.4. Transition Lanes are low priority lanes.

export function requestTransitionLane(): Lane {
// The algorithm for assigning an update to a lane should be stable for all
// updates at the same priority within the same event. To do this, the
// inputs to the algorithm must be the same.
//
// The trick we use is to cache the first of each of these inputs within an
// event. Then reset the cached values once we can be sure the event is
// over. Our heuristic for that is whenever we enter a concurrent work loop.
if (currentEventTransitionLane === NoLane) {
// All transitions within the same event are assigned the same lane.
currentEventTransitionLane = claimNextTransitionLane();
}
return currentEventTransitionLane;
}
export function claimNextTransitionLane(): Lane {
// Cycle through the lanes, assigning each new transition to the next lane.
// In most cases, this means every transition gets its own lane, until we
// run out of lanes and cycle back to the beginning.
const lane = nextTransitionLane;
nextTransitionLane <<= 1;

go to next transition lane, by shifting to left

if ((nextTransitionLane & TransitionLanes) === NoLanes) {
nextTransitionLane = TransitionLane1;

if no more transition lanes, go to the first transition lane

}
return lane;
}
export const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /* */ 0b0000000000000000000000000000000;
export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010;
export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000001000;
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane = /* */ 0b0000000000000000000000000100000;
export const SyncUpdateLanes: Lane = /* */ 0b0000000000000000000000000101010;
const TransitionLanes: Lanes = /* */ 0b0000000011111111111111110000000;

in total, there are 16 transition lanes,

and these lanes have lower priority than SyncLane .etc

const TransitionLane1: Lane = /* */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /* */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /* */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /* */ 0b0000000000000000000010000000000;
const TransitionLane5: Lane = /* */ 0b0000000000000000000100000000000;
const TransitionLane6: Lane = /* */ 0b0000000000000000001000000000000;
const TransitionLane7: Lane = /* */ 0b0000000000000000010000000000000;
const TransitionLane8: Lane = /* */ 0b0000000000000000100000000000000;
const TransitionLane9: Lane = /* */ 0b0000000000000001000000000000000;
const TransitionLane10: Lane = /* */ 0b0000000000000010000000000000000;
const TransitionLane11: Lane = /* */ 0b0000000000000100000000000000000;
const TransitionLane12: Lane = /* */ 0b0000000000001000000000000000000;
const TransitionLane13: Lane = /* */ 0b0000000000010000000000000000000;
const TransitionLane14: Lane = /* */ 0b0000000000100000000000000000000;
const TransitionLane15: Lane = /* */ 0b0000000001000000000000000000000;
const TransitionLane16: Lane = /* */ 0b0000000010000000000000000000000;
export function requestTransitionLane(): Lane {
// The algorithm for assigning an update to a lane should be stable for all
// updates at the same priority within the same event. To do this, the
// inputs to the algorithm must be the same.
//
// The trick we use is to cache the first of each of these inputs within an
// event. Then reset the cached values once we can be sure the event is
// over. Our heuristic for that is whenever we enter a concurrent work loop.
if (currentEventTransitionLane === NoLane) {
// All transitions within the same event are assigned the same lane.
currentEventTransitionLane = claimNextTransitionLane();
}
return currentEventTransitionLane;
}
export function claimNextTransitionLane(): Lane {
// Cycle through the lanes, assigning each new transition to the next lane.
// In most cases, this means every transition gets its own lane, until we
// run out of lanes and cycle back to the beginning.
const lane = nextTransitionLane;
nextTransitionLane <<= 1;

go to next transition lane, by shifting to left

if ((nextTransitionLane & TransitionLanes) === NoLanes) {
nextTransitionLane = TransitionLane1;

if no more transition lanes, go to the first transition lane

}
return lane;
}
export const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /* */ 0b0000000000000000000000000000000;
export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010;
export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000001000;
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane = /* */ 0b0000000000000000000000000100000;
export const SyncUpdateLanes: Lane = /* */ 0b0000000000000000000000000101010;
const TransitionLanes: Lanes = /* */ 0b0000000011111111111111110000000;

in total, there are 16 transition lanes,

and these lanes have lower priority than SyncLane .etc

const TransitionLane1: Lane = /* */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /* */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /* */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /* */ 0b0000000000000000000010000000000;
const TransitionLane5: Lane = /* */ 0b0000000000000000000100000000000;
const TransitionLane6: Lane = /* */ 0b0000000000000000001000000000000;
const TransitionLane7: Lane = /* */ 0b0000000000000000010000000000000;
const TransitionLane8: Lane = /* */ 0b0000000000000000100000000000000;
const TransitionLane9: Lane = /* */ 0b0000000000000001000000000000000;
const TransitionLane10: Lane = /* */ 0b0000000000000010000000000000000;
const TransitionLane11: Lane = /* */ 0b0000000000000100000000000000000;
const TransitionLane12: Lane = /* */ 0b0000000000001000000000000000000;
const TransitionLane13: Lane = /* */ 0b0000000000010000000000000000000;
const TransitionLane14: Lane = /* */ 0b0000000000100000000000000000000;
const TransitionLane15: Lane = /* */ 0b0000000001000000000000000000000;
const TransitionLane16: Lane = /* */ 0b0000000010000000000000000000000;

Transition lanes have low priority, which means they could be interrupted. This is the gold of Concurrent Mode, to understand how it could be interrupted, please refer to How the scheduler works.

2.5 updateTransition()

This is when useTransition() is called after the initial mount.

js
function updateTransition(): [
boolean,
(callback: () => void, options?: StartTransitionOptions) => void,
] {
const [booleanOrThenable] = updateState(false);
const hook = updateWorkInProgressHook();
const start = hook.memoizedState;
const isPending =
typeof booleanOrThenable === 'boolean'
? booleanOrThenable
: // This will suspend until the async action scope has finished.
useThenable(booleanOrThenable);
return [isPending, start];
}
js
function updateTransition(): [
boolean,
(callback: () => void, options?: StartTransitionOptions) => void,
] {
const [booleanOrThenable] = updateState(false);
const hook = updateWorkInProgressHook();
const start = hook.memoizedState;
const isPending =
typeof booleanOrThenable === 'boolean'
? booleanOrThenable
: // This will suspend until the async action scope has finished.
useThenable(booleanOrThenable);
return [isPending, start];
}

So from the code, we can see it basically just returns the data from the hook.

2.6 startTransition()

startTransition() is a global imperative API that could be used outside of components.

export function startTransition(
scope: () => void,
options?: StartTransitionOptions,
) {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = ({}: BatchConfigTransition);

It merely sets the global flag for transition

const currentTransition = ReactCurrentBatchConfig.transition;
try {
scope();
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}
export function startTransition(
scope: () => void,
options?: StartTransitionOptions,
) {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = ({}: BatchConfigTransition);

It merely sets the global flag for transition

const currentTransition = ReactCurrentBatchConfig.transition;
try {
scope();
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}

It is pretry simple, like part of useTranstion() without isPending.

3. Understand the demos better with our learnings.

Let’s look at the use cases listed on offical doc to see how they are possible.

3.1 Use case 1 - Marking a state update as a non-blocking transition

demo

If without useTransition(), here is what happens

To understand above statement, let’s read following code showing how scheduling re-render works.

// Use this function to schedule a task for a root. There's only one task per
// root; if a task was already scheduled, we'll check to make sure the priority
// of the existing task is the same as the priority of the next level that the
// root has work on. This function is called on every update, and right before
// exiting a task.
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
const existingCallbackNode = root.callbackNode;
// Check if any lanes are being starved by other work. If so, mark them as
// expired so we know to work on those next.
markStarvedLanesAsExpired(root, currentTime);
// Determine the next lanes to work on, and their priority.
const nextLanes = getNextLanes(

getNextLanes() returns highest priority lane

if there are SyncLane and Transition Lanes, SyncLane will be chosen

root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
if (nextLanes === NoLanes) {
// Special case: There's nothing to work on.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackNode = null;
root.callbackPriority = NoLane;
return;
}
...
if (existingCallbackNode != null) {
// Cancel the existing callback. We'll schedule a new one below.
cancelCallback(existingCallbackNode);
~~~~~~~~~~~~~~

This is important!

If a re-render is not done, and we schedule a new one,

the old one is going to be canceled.

This is how interruption happens

}
// Schedule a new callback.
let newCallbackNode;
if (newCallbackPriority === SyncLane) {
// Special case: Sync React callbacks are scheduled on a special
// internal queue
if (root.tag === LegacyRoot) {
scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null, root));
} else {
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));

For SyncLane, the reconciliation is sync work, not concurrent mode

meaning there is no yielding to main thread, potentially becomes blocking

}
if (supportsMicrotasks) {
scheduleMicrotask(() => {
// In Safari, appending an iframe forces microtasks to run.
// https://github.com/facebook/react/issues/22459
// We don't support running callbacks in the middle of render
// or commit so we need to check against that.
if (
(executionContext & (RenderContext | CommitContext)) ===
NoContext
) {
// Note that this would still prematurely flush the callbacks
// if this happens outside render or commit phase (e.g. in an event).
flushSyncCallbacks();
}
});
} else {
// Flush the queue in an Immediate task.
scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
}
newCallbackNode = null;
} else {
let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority;
break;
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),

If not SyncLane, concurrent mode is used,

reconciliation yields to main thread time to time

which makes UI interactive and thus possible to cancel previous re-render

);
}
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}
// Use this function to schedule a task for a root. There's only one task per
// root; if a task was already scheduled, we'll check to make sure the priority
// of the existing task is the same as the priority of the next level that the
// root has work on. This function is called on every update, and right before
// exiting a task.
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
const existingCallbackNode = root.callbackNode;
// Check if any lanes are being starved by other work. If so, mark them as
// expired so we know to work on those next.
markStarvedLanesAsExpired(root, currentTime);
// Determine the next lanes to work on, and their priority.
const nextLanes = getNextLanes(

getNextLanes() returns highest priority lane

if there are SyncLane and Transition Lanes, SyncLane will be chosen

root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
if (nextLanes === NoLanes) {
// Special case: There's nothing to work on.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackNode = null;
root.callbackPriority = NoLane;
return;
}
...
if (existingCallbackNode != null) {
// Cancel the existing callback. We'll schedule a new one below.
cancelCallback(existingCallbackNode);
~~~~~~~~~~~~~~

This is important!

If a re-render is not done, and we schedule a new one,

the old one is going to be canceled.

This is how interruption happens

}
// Schedule a new callback.
let newCallbackNode;
if (newCallbackPriority === SyncLane) {
// Special case: Sync React callbacks are scheduled on a special
// internal queue
if (root.tag === LegacyRoot) {
scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null, root));
} else {
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));

For SyncLane, the reconciliation is sync work, not concurrent mode

meaning there is no yielding to main thread, potentially becomes blocking

}
if (supportsMicrotasks) {
scheduleMicrotask(() => {
// In Safari, appending an iframe forces microtasks to run.
// https://github.com/facebook/react/issues/22459
// We don't support running callbacks in the middle of render
// or commit so we need to check against that.
if (
(executionContext & (RenderContext | CommitContext)) ===
NoContext
) {
// Note that this would still prematurely flush the callbacks
// if this happens outside render or commit phase (e.g. in an event).
flushSyncCallbacks();
}
});
} else {
// Flush the queue in an Immediate task.
scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
}
newCallbackNode = null;
} else {
let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority;
break;
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),

If not SyncLane, concurrent mode is used,

reconciliation yields to main thread time to time

which makes UI interactive and thus possible to cancel previous re-render

);
}
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}

With above knowledge, it is easy to understand why the demo behaves differently when using useTransition().

3.2 Use case 2 - Updating the parent component in a transition

demo

Following use case 1, Transition is a term on the task in scheduler, which means it is about the full re-render not about specific fiber so useTransition() on parent also works.

In fact the global startTransition() API makes it possible to trigger transition from anywhere.

3.3 Use case 3 - Displaying a pending visual state during the transition

demo

Remember on section 2.2 that inside of useTransition() there are semantically two calls of setState(), the first call is not transition so it is SyncLane and the state that holds isPending will be set successfully.

This is why we can see an indicator.

3.4 Use case 4 - Preventing unwanted loading indicators

This is more interesting, it has different semantics from above use cases. React.dev has more detailed explanation on it.

Since this is about the scenario when we need to flip Suspense contents to fallbacks, let’s read the code related to Suspense.

function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
switch (workInProgress.tag) {
...
case SuspenseComponent: {
popSuspenseContext(workInProgress);
const nextState: null | SuspenseState = workInProgress.memoizedState;
}
const nextDidTimeout = nextState !== null;
const prevDidTimeout =
current !== null &&
(current.memoizedState: null | SuspenseState) !== null;
...
// If the suspended state of the boundary changes, we need to schedule
// a passive effect, which is when we process the transitions
if (nextDidTimeout !== prevDidTimeout) {
if (enableTransitionTracing) {
const offscreenFiber: Fiber = (workInProgress.child: any);
offscreenFiber.flags |= Passive;
}
// If the suspended state of the boundary changes, we need to schedule
// an effect to toggle the subtree's visibility. When we switch from
// fallback -> primary, the inner Offscreen fiber schedules this effect
// as part of its normal complete phase. But when we switch from
// primary -> fallback, the inner Offscreen fiber does not have a complete
// phase. So we need to schedule its effect here.
//
// We also use this flag to connect/disconnect the effects, but the same
// logic applies: when re-connecting, the Offscreen fiber's complete
// phase will handle scheduling the effect. It's only when the fallback
// is active that we have to do anything special.
if (nextDidTimeout) {
const offscreenFiber: Fiber = (workInProgress.child: any);
offscreenFiber.flags |= Visibility;
// TODO: This will still suspend a synchronous tree if anything
// in the concurrent tree already suspended during this render.
// This is a known bug.
if ((workInProgress.mode & ConcurrentMode) !== NoMode) {
// TODO: Move this back to throwException because this is too late
// if this is a large tree which is common for initial loads. We
// don't know if we should restart a render or not until we get
// this marker, and this is too late.
// If this render already had a ping or lower pri updates,
// and this is the first time we know we're going to suspend we
// should be able to immediately restart from within throwException.
const hasInvisibleChildContext =
current === null &&
(workInProgress.memoizedProps.unstable_avoidThisFallback !==
true ||
!enableSuspenseAvoidThisFallback);
if (
hasInvisibleChildContext ||
hasSuspenseContext(
suspenseStackCursor.current,
(InvisibleParentSuspenseContext: SuspenseContext),
)
) {
// If this was in an invisible tree or a new render, then showing
// this boundary is ok.
renderDidSuspend();
} else {
// Otherwise, we're going to have to hide content so we should
// suspend for longer if possible.
renderDidSuspendDelayIfPossible();

As the name implies, React tries to avoid flipping back to fallbackss

}
}
}
}
}
}
}
export function renderDidSuspendDelayIfPossible(): void {
if (
workInProgressRootExitStatus === RootInProgress ||
workInProgressRootExitStatus === RootSuspended ||
workInProgressRootExitStatus === RootErrored
) {
workInProgressRootExitStatus = RootSuspendedWithDelay;

This is a global variable holding the result of full re-render

}
...
}
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
switch (workInProgress.tag) {
...
case SuspenseComponent: {
popSuspenseContext(workInProgress);
const nextState: null | SuspenseState = workInProgress.memoizedState;
}
const nextDidTimeout = nextState !== null;
const prevDidTimeout =
current !== null &&
(current.memoizedState: null | SuspenseState) !== null;
...
// If the suspended state of the boundary changes, we need to schedule
// a passive effect, which is when we process the transitions
if (nextDidTimeout !== prevDidTimeout) {
if (enableTransitionTracing) {
const offscreenFiber: Fiber = (workInProgress.child: any);
offscreenFiber.flags |= Passive;
}
// If the suspended state of the boundary changes, we need to schedule
// an effect to toggle the subtree's visibility. When we switch from
// fallback -> primary, the inner Offscreen fiber schedules this effect
// as part of its normal complete phase. But when we switch from
// primary -> fallback, the inner Offscreen fiber does not have a complete
// phase. So we need to schedule its effect here.
//
// We also use this flag to connect/disconnect the effects, but the same
// logic applies: when re-connecting, the Offscreen fiber's complete
// phase will handle scheduling the effect. It's only when the fallback
// is active that we have to do anything special.
if (nextDidTimeout) {
const offscreenFiber: Fiber = (workInProgress.child: any);
offscreenFiber.flags |= Visibility;
// TODO: This will still suspend a synchronous tree if anything
// in the concurrent tree already suspended during this render.
// This is a known bug.
if ((workInProgress.mode & ConcurrentMode) !== NoMode) {
// TODO: Move this back to throwException because this is too late
// if this is a large tree which is common for initial loads. We
// don't know if we should restart a render or not until we get
// this marker, and this is too late.
// If this render already had a ping or lower pri updates,
// and this is the first time we know we're going to suspend we
// should be able to immediately restart from within throwException.
const hasInvisibleChildContext =
current === null &&
(workInProgress.memoizedProps.unstable_avoidThisFallback !==
true ||
!enableSuspenseAvoidThisFallback);
if (
hasInvisibleChildContext ||
hasSuspenseContext(
suspenseStackCursor.current,
(InvisibleParentSuspenseContext: SuspenseContext),
)
) {
// If this was in an invisible tree or a new render, then showing
// this boundary is ok.
renderDidSuspend();
} else {
// Otherwise, we're going to have to hide content so we should
// suspend for longer if possible.
renderDidSuspendDelayIfPossible();

As the name implies, React tries to avoid flipping back to fallbackss

}
}
}
}
}
}
}
export function renderDidSuspendDelayIfPossible(): void {
if (
workInProgressRootExitStatus === RootInProgress ||
workInProgressRootExitStatus === RootSuspended ||
workInProgressRootExitStatus === RootErrored
) {
workInProgressRootExitStatus = RootSuspendedWithDelay;

This is a global variable holding the result of full re-render

}
...
}

So React doesn’t like Suspense fallbacks to be rendered after contents already being revealed, which is quite reasonable.

RootSuspendedWithDelay is a status that is checked right before React commits changes to DOM.

function finishConcurrentRender(
root: FiberRoot,
exitStatus: RootExitStatus,
finishedWork: Fiber,
lanes: Lanes,
) {
// TODO: The fact that most of these branches are identical suggests that some
// of the exit statuses are not best modeled as exit statuses and should be
// tracked orthogonally.
switch (exitStatus) {
case RootInProgress:
case RootFatalErrored: {
throw new Error('Root did not complete. This is a bug in React.');
}
case RootSuspendedWithDelay: {
if (includesOnlyTransitions(lanes)) {
// This is a transition, so we should exit without committing a
// placeholder and without scheduling a timeout. Delay indefinitely
// until we receive more data.
markRootSuspended(root, lanes);
return;

Here it returns if the lanes are all transitions under RootSuspendedWithDelay,

Semantically it says we really don't like to flicker contents back to Suspense fallback

especially we already know it is low priority(transition)

}
// Commit the placeholder.
break;
}
case RootErrored:
case RootSuspended:
case RootCompleted: {
break;
}
default: {
throw new Error('Unknown root exit status.');
}
}
...
commitRootWhenReady(
root,
finishedWork,
workInProgressRootRecoverableErrors,
workInProgressTransitions,
lanes,
);
}
function finishConcurrentRender(
root: FiberRoot,
exitStatus: RootExitStatus,
finishedWork: Fiber,
lanes: Lanes,
) {
// TODO: The fact that most of these branches are identical suggests that some
// of the exit statuses are not best modeled as exit statuses and should be
// tracked orthogonally.
switch (exitStatus) {
case RootInProgress:
case RootFatalErrored: {
throw new Error('Root did not complete. This is a bug in React.');
}
case RootSuspendedWithDelay: {
if (includesOnlyTransitions(lanes)) {
// This is a transition, so we should exit without committing a
// placeholder and without scheduling a timeout. Delay indefinitely
// until we receive more data.
markRootSuspended(root, lanes);
return;

Here it returns if the lanes are all transitions under RootSuspendedWithDelay,

Semantically it says we really don't like to flicker contents back to Suspense fallback

especially we already know it is low priority(transition)

}
// Commit the placeholder.
break;
}
case RootErrored:
case RootSuspended:
case RootCompleted: {
break;
}
default: {
throw new Error('Unknown root exit status.');
}
}
...
commitRootWhenReady(
root,
finishedWork,
workInProgressRootRecoverableErrors,
workInProgressTransitions,
lanes,
);
}

We can see the magic here - If under transition and React runs into the need of fliping back Suspense contents to fallbacks, React just ignore it and doesn’t commit the changes to DOM. And because of how Suspense works, when the thrown thenable resolves a re-render is scheduled, which renders the right contents.

4. Summary

useTranstion() is a powerful tool with which we can lower the priority of certain updates. Because events like click are actually triggering updates in synchonous mode, useTransition() works as an explicit opt-in of Concurrent Mode.

Comparing to avoiding blocking updates, a better use case of it would be avoiding unnecessary Suspense fallbacks, which I think we should use as much as we can.

Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!

😳 Would you like to share my post to more people ?    

❮ Prev: Introducing Shaku - a family of tools that help write tech articles

Next: Introducing QuickCode 75 - a companion app for Grind 75 / Blind 75