When do useEffect() callbacks get run? Before paint or after paint?

Short answer: Most of time useEffect() callbacks are run after paint but not always.

From React.dev, it is described as below(on 2023/08/09).

If your Effect wasn’t caused by an interaction (like a click), React will let the browser paint the updated screen first before running your Effect. If your Effect is doing something visual (for example, positioning a tooltip), and the delay is noticeable (for example, it flickers), replace useEffect with useLayoutEffect.

Sadly the official contents is not accurate. Even if your Effect wasn’t caused by an interaction, React might still paint after running your Effect.

I’ve explained it in How does useEffect() work internally in React?, but I didn’t touch the details of timing compared to browser paint. We’ll figure it out today with some demos.

1. Demo 1 - useEffect() callback run before paint.

What gets logged from following code snippet?

import React, { useState, useEffect } from "react";
export default function App() {
const [state] = useState(0);
console.log(1);
--------------
useEffect(() => {
console.log(2);
--------------
}, [state]);
Promise.resolve().then(() => console.log(3));
--------------
setTimeout(() => console.log(4), 0);
--------------
return <div>open console to see the logs</div>;
}
const root = createRoot(document.getElementById("root"));
root.render(<App />);
import React, { useState, useEffect } from "react";
export default function App() {
const [state] = useState(0);
console.log(1);
--------------
useEffect(() => {
console.log(2);
--------------
}, [state]);
Promise.resolve().then(() => console.log(3));
--------------
setTimeout(() => console.log(4), 0);
--------------
return <div>open console to see the logs</div>;
}
const root = createRoot(document.getElementById("root"));
root.render(<App />);
See logs via Codesandbox

The result is quite surprising!

If useEffect() is async, then its callback should be run after the Promise then callback. The result should be 1 3 4 2.

But clearly we see that is not the case, the logs are 1 2 3 4, meaning it is run before paint, not after.

2. Demo 2 - useEffect() callback run after paint.

Now let’s try a slightly different code snippet, where a blocking while loop is added.

import React, { useState, useEffect } from "react";
export default function App() {
const [state] = useState(0);
console.log(1);
--------------
let start = Date.now();
while (Date.now() - start < 50) {}
useEffect(() => {
console.log(2);
--------------
}, [state]);
Promise.resolve().then(() => console.log(3));
--------------
setTimeout(() => console.log(4), 0);
--------------
return <div>open console to see the logs</div>;
}
const root = createRoot(document.getElementById("root"));
root.render(<App />);
import React, { useState, useEffect } from "react";
export default function App() {
const [state] = useState(0);
console.log(1);
--------------
let start = Date.now();
while (Date.now() - start < 50) {}
useEffect(() => {
console.log(2);
--------------
}, [state]);
Promise.resolve().then(() => console.log(3));
--------------
setTimeout(() => console.log(4), 0);
--------------
return <div>open console to see the logs</div>;
}
const root = createRoot(document.getElementById("root"));
root.render(<App />);
See logs via Codesandbox

Wow, now it becomes async - 1 3 2 4, which feels more natural.

So why these two demos log different ? Let’s continue.

3. React Scheduler tries to process multiple tasks(sync) before yielding.

I’ve briefly mentioned this in how React Scheduler works internally, but let’s take a closer look.

In commit phase after rendering is done, useEffect() callbacks are scheduled to be run.

function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
...
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
// workInProgressTransitions might be overwritten, so we want
// to store it in pendingPassiveTransitions until they get processed
// We need to pass this through as an argument to commitRoot
// because workInProgressTransitions might have changed between
// the previous render and commit if we throttle the commit
// with setTimeout
pendingPassiveTransitions = transitions;
scheduleCallback(NormalSchedulerPriority, () => {
----------------

This is async Most of the time

flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});
}
}
...
}
function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
...
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
// workInProgressTransitions might be overwritten, so we want
// to store it in pendingPassiveTransitions until they get processed
// We need to pass this through as an argument to commitRoot
// because workInProgressTransitions might have changed between
// the previous render and commit if we throttle the commit
// with setTimeout
pendingPassiveTransitions = transitions;
scheduleCallback(NormalSchedulerPriority, () => {
----------------

This is async Most of the time

flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});
}
}
...
}
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: { delay: number }
): Task {
...
var expirationTime = startTime + timeout;
var newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};

new task is created

if (startTime > currentTime) {
...
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
------------------------

Task is pushed, but not scheduled

// Schedule a host callback, if needed. If we're already performing work,
-----------------------------------
// wait until the next time we yield.
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback();
-------------------

requestHostCallback() starts task processing by scheduling

}

So if Scheduler is already processing tasks, there won't be scheduling here

}
return newTask;
}
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: { delay: number }
): Task {
...
var expirationTime = startTime + timeout;
var newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};

new task is created

if (startTime > currentTime) {
...
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
------------------------

Task is pushed, but not scheduled

// Schedule a host callback, if needed. If we're already performing work,
-----------------------------------
// wait until the next time we yield.
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback();
-------------------

requestHostCallback() starts task processing by scheduling

}

So if Scheduler is already processing tasks, there won't be scheduling here

}
return newTask;
}

React Scheduler tries to process multiple tasks at once. commitRoot() is called inside of previous rendering task. This means when scheduleCallback() for useEffect() is called, isPerformingWork is true thus Scheduler just pushes the task into the queue without scheduling and wish it could be processed together.

But of course, Scheduler doesn’t know the time cost of each task until actually done processing them, so before running each task, it checks again if needs to yield and schedule the processing of rest tasks by looking at if there is time left.

function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
------------------------
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
-----------------------------------------

Ok we cost too much time, let's give browser some time to paint!

) {
// This currentTask hasn't expired, and we've reached the deadline.
break;

After break here, another round of workLoop is scheduled.

}
const callback = currentTask.callback;
...
}
}
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
------------------------
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
-----------------------------------------

Ok we cost too much time, let's give browser some time to paint!

) {
// This currentTask hasn't expired, and we've reached the deadline.
break;

After break here, another round of workLoop is scheduled.

}
const callback = currentTask.callback;
...
}
}

So now we are able to understand our first two demos.

  1. for both demos, a new task is created because of useEffect() under scheduleCallback(). But when it is called we are still inside previous rendering task, the new task is just pushed, not scheduled asynchronously.
  2. Scheduler tries to process the tasks synchronously. For demo 1, the whole rendering doesn’t cost too much time, so it is run synchronously. But for demo 2, the rendering cost too much time, Scheduler yield to main thread and schedules it to async.

Great, now you can see that even the effects are not caused by user interaction, we still cannot be certain if they are run before paint or after paint.

Alright, now let’s look at some other cases.

4. Demo 3 - useEffect() under user interaction are run before paint.

import React, { useState, useEffect } from "react";
export default function App() {
const [state, setState] = useState(0);
console.log(1);
---------------
let start = Date.now();
while (Date.now() - start < 50) {}
useEffect(() => {
console.log(2);
---------------
}, [state]);
Promise.resolve().then(() => console.log(3));
---------------
setTimeout(() => console.log(4), 0);
---------------
return (
<div>
click <button onClick={() => setState((state) => state + 1)}>rerender</button>{" "}
{/* ------------------------*/}
and open console to see the logs{" "}
</div>
);
}
import React, { useState, useEffect } from "react";
export default function App() {
const [state, setState] = useState(0);
console.log(1);
---------------
let start = Date.now();
while (Date.now() - start < 50) {}
useEffect(() => {
console.log(2);
---------------
}, [state]);
Promise.resolve().then(() => console.log(3));
---------------
setTimeout(() => console.log(4), 0);
---------------
return (
<div>
click <button onClick={() => setState((state) => state + 1)}>rerender</button>{" "}
{/* ------------------------*/}
and open console to see the logs{" "}
</div>
);
}
See logs via Codesandbox

The result is 1 3 4 2 1 2 3 4. So clearly because of the click event, useEffect() callback is run synchronously before paint.

The thing is that according to demo 2 React Scheduler should schedule the run asynchronously, what happens under event handler?

The answer is, it works the same but there is one more extra step for re-render under SyncLane that run passive effects synchronously. And re-render caused by user interaction are SyncLane.

function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
...
// If there are pending passive effects, schedule a callback to process them.
// Do this as early as possible, so it is queued before anything else that
// might get scheduled in the commit phase. (See #16714.)
// TODO: Delete all other places that schedule the passive effect callback
// They're redundant.
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
// workInProgressTransitions might be overwritten, so we want
// to store it in pendingPassiveTransitions until they get processed
// We need to pass this through as an argument to commitRoot
// because workInProgressTransitions might have changed between
// the previous render and commit if we throttle the commit
// with setTimeout
pendingPassiveTransitions = transitions;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});

This is already covered, and under user interactions, this is run as well!

}
}
...
// Always call this before exiting `commitRoot`, to ensure that any
// additional work on this root is scheduled.
ensureRootIsScheduled(root, now());
...
// If the passive effects are the result of a discrete render, flush them
----------------------------------------------------------------------
// synchronously at the end of the current task so that the result is
------------------------------------------------------------------
// immediately observable. Otherwise, we assume that they are not
-----------------------
// order-dependent and do not need to be observed by external systems, so we
// can wait until after paint.
// TODO: We can optimize this by not scheduling the callback earlier. Since we
// currently schedule the callback in multiple places, will wait until those
// are consolidated.
if (
includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
root.tag !== LegacyRoot
) {
flushPassiveEffects();
}

flushPassiveEffects() is called again, synchronously.

So when the scheduled flushPassiveEffects() gets run, the effects are empty

// If layout work was scheduled, flush it now.
flushSyncCallbacks();
return null;
}
function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
...
// If there are pending passive effects, schedule a callback to process them.
// Do this as early as possible, so it is queued before anything else that
// might get scheduled in the commit phase. (See #16714.)
// TODO: Delete all other places that schedule the passive effect callback
// They're redundant.
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
// workInProgressTransitions might be overwritten, so we want
// to store it in pendingPassiveTransitions until they get processed
// We need to pass this through as an argument to commitRoot
// because workInProgressTransitions might have changed between
// the previous render and commit if we throttle the commit
// with setTimeout
pendingPassiveTransitions = transitions;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});

This is already covered, and under user interactions, this is run as well!

}
}
...
// Always call this before exiting `commitRoot`, to ensure that any
// additional work on this root is scheduled.
ensureRootIsScheduled(root, now());
...
// If the passive effects are the result of a discrete render, flush them
----------------------------------------------------------------------
// synchronously at the end of the current task so that the result is
------------------------------------------------------------------
// immediately observable. Otherwise, we assume that they are not
-----------------------
// order-dependent and do not need to be observed by external systems, so we
// can wait until after paint.
// TODO: We can optimize this by not scheduling the callback earlier. Since we
// currently schedule the callback in multiple places, will wait until those
// are consolidated.
if (
includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
root.tag !== LegacyRoot
) {
flushPassiveEffects();
}

flushPassiveEffects() is called again, synchronously.

So when the scheduled flushPassiveEffects() gets run, the effects are empty

// If layout work was scheduled, flush it now.
flushSyncCallbacks();
return null;
}

This is done intentionally because React thinks it is more important to show users the latest UI after interactions.

5. Demo 4 - useEffect() might be hoisted by useLayoutEffect() and run before paint

Now what if we have a re-render in layout effects?

import React, { useState, useEffect, useLayoutEffect } from "react";
export default function App() {
const [state, setState] = useState(0);
console.log(1);
let start = Date.now();
while (Date.now() - start < 50) {}
useEffect(() => {
console.log(2);
}, [state]);
useLayoutEffect(() => {
setState((state) => state + 1);
}, []);
Promise.resolve().then(() => console.log(3));
setTimeout(() => console.log(4), 0);
return <div>open console to see the logs </div>;
}
import React, { useState, useEffect, useLayoutEffect } from "react";
export default function App() {
const [state, setState] = useState(0);
console.log(1);
let start = Date.now();
while (Date.now() - start < 50) {}
useEffect(() => {
console.log(2);
}, [state]);
useLayoutEffect(() => {
setState((state) => state + 1);
}, []);
Promise.resolve().then(() => console.log(3));
setTimeout(() => console.log(4), 0);
return <div>open console to see the logs </div>;
}
See logs via Codesandbox

The answer is 1 2 1 2 3 3 4 4. This needs some explanation on useState().

First of all, updates triggered in commit phase are treated as DiscreteEventPriority, which means SyncLane, same as under user interactions.

function commitRoot(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
) {
// TODO: This no longer makes any sense. We already wrap the mutation and
// layout phases. Should be able to remove.
const previousUpdateLanePriority = getCurrentUpdatePriority();
const prevTransition = ReactCurrentBatchConfig.transition;
try {
ReactCurrentBatchConfig.transition = null;
setCurrentUpdatePriority(DiscreteEventPriority);
-----------------------------------------------

SyncLane for updates in commit phase!

commitRootImpl(
root,
recoverableErrors,
transitions,
previousUpdateLanePriority,
);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
setCurrentUpdatePriority(previousUpdateLanePriority);
}
return null;
}
function commitRoot(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
) {
// TODO: This no longer makes any sense. We already wrap the mutation and
// layout phases. Should be able to remove.
const previousUpdateLanePriority = getCurrentUpdatePriority();
const prevTransition = ReactCurrentBatchConfig.transition;
try {
ReactCurrentBatchConfig.transition = null;
setCurrentUpdatePriority(DiscreteEventPriority);
-----------------------------------------------

SyncLane for updates in commit phase!

commitRootImpl(
root,
recoverableErrors,
transitions,
previousUpdateLanePriority,
);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
setCurrentUpdatePriority(previousUpdateLanePriority);
}
return null;
}

When setState() is called, it marks the Fiber Nodes that needs to be worked on and then schedules the re-render through ensureRootIsScheduled().

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
...
// Determine the next lanes to work on, and their priority.
const nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
...
// We use the highest priority lane to represent the priority of the callback.
const newCallbackPriority = getHighestPriorityLane(nextLanes);
...
// Schedule a new callback.
let newCallbackNode;
if (newCallbackPriority === SyncLane) {
-------------------------------------

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));
------------------------------------------
}
...
}
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
...
// Determine the next lanes to work on, and their priority.
const nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
...
// We use the highest priority lane to represent the priority of the callback.
const newCallbackPriority = getHighestPriorityLane(nextLanes);
...
// Schedule a new callback.
let newCallbackNode;
if (newCallbackPriority === SyncLane) {
-------------------------------------

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));
------------------------------------------
}
...
}
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}

And in performSyncWorkOnRoot() it runs the useEffect() callbacks!

export function performSyncWorkOnRoot(root: FiberRoot): null {
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
syncNestedUpdateFlag();
}
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
throw new Error("Should not already be working.");
}
flushPassiveEffects();
----------------------
}
export function performSyncWorkOnRoot(root: FiberRoot): null {
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
syncNestedUpdateFlag();
}
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
throw new Error("Should not already be working.");
}
flushPassiveEffects();
----------------------
}

scheduleSyncCallback() pushes the callback into syncQueue

export function scheduleSyncCallback(callback: SchedulerCallback) {
// Push this callback into an internal queue. We'll flush these either in
// the next tick, or earlier if something calls `flushSyncCallbackQueue`.
if (syncQueue === null) {
syncQueue = [callback];
} else {
// Push onto existing queue. Don't need to schedule a callback because
// we already scheduled one when we created the queue.
syncQueue.push(callback);
------------------------
}
}
export function scheduleSyncCallback(callback: SchedulerCallback) {
// Push this callback into an internal queue. We'll flush these either in
// the next tick, or earlier if something calls `flushSyncCallbackQueue`.
if (syncQueue === null) {
syncQueue = [callback];
} else {
// Push onto existing queue. Don't need to schedule a callback because
// we already scheduled one when we created the queue.
syncQueue.push(callback);
------------------------
}
}

And inside commitRoot(), syncQueue is flushed synchronously.

function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
...
// If the passive effects are the result of a discrete render, flush them
// synchronously at the end of the current task so that the result is
// immediately observable. Otherwise, we assume that they are not
// order-dependent and do not need to be observed by external systems, so we
// can wait until after paint.
// TODO: We can optimize this by not scheduling the callback earlier. Since we
// currently schedule the callback in multiple places, will wait until those
// are consolidated.
if (
includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
root.tag !== LegacyRoot
) {
flushPassiveEffects();
}
...
// If layout work was scheduled, flush it now.
flushSyncCallbacks();

So re-render from layout effects starts synchronously from here!

And also under SyncLane!

Concurrent Mode is not used, thus commitRoot() is called synchronously later again

return null;
}
function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
...
// If the passive effects are the result of a discrete render, flush them
// synchronously at the end of the current task so that the result is
// immediately observable. Otherwise, we assume that they are not
// order-dependent and do not need to be observed by external systems, so we
// can wait until after paint.
// TODO: We can optimize this by not scheduling the callback earlier. Since we
// currently schedule the callback in multiple places, will wait until those
// are consolidated.
if (
includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
root.tag !== LegacyRoot
) {
flushPassiveEffects();
}
...
// If layout work was scheduled, flush it now.
flushSyncCallbacks();

So re-render from layout effects starts synchronously from here!

And also under SyncLane!

Concurrent Mode is not used, thus commitRoot() is called synchronously later again

return null;
}

Now we are able to understand the logs in demo 4.

  1. start committing → 1
  2. schedules flushPassiveEffects() → async
  3. run layout effects, trigger re-render, schedules performSyncWorkOnRoot() in sync
  4. flushPassiveEffects() called in performSyncWorkOnRoot()2
  5. re-render done, start committing again → 1
  6. schedules flushPassiveEffects() → async
  7. since under SyncLane, flushPassiveEffects() called as explained in demo 3 → 2
  8. so far all sync, so then callbacks are run then setTimeouts → 3 3 4 4.
  9. scheduled flushPassiveEffects() are run → but they are already processed.

6. Summary

We’ve covered a few cases where useEffect() callbacks are run before paint, there are actually more. But we’ve already proved that the explanation on React.dev is not accurate (Be skeptical!).

I’d explain the timing of running useEffect() callbacks as follows.

useEffect() callbacks are run after DOM mutation is done. Most of the time, they are run asynchronously after paint, but React might run them synchronously before paint when it is more important to show the latest UI(for example when re-render is caused by user interactions or scheduled under layout effects), or simply if React has the time internally to do so.

I created a pull request to fix this on React.dev, hope it could get merged.

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: How useSyncExternalStore() works internally in React?

Next: Introducing Shaku Snippet - generate code screenshot with annotation