How React 18 Handles Events: Sync vs Async
When you build a text editor for the web, you eventually run into things you used to take for granted that you now have to implement yourself.
Over roughly a year and a half of building a text editor, I ended up implementing things I had assumed the web would just provide, and along the way I ran into some pretty unusual problems.
Editors often have requirements (multiple carets, precise control over text position, smooth UX) that the browser's default caret can't satisfy, so sometimes you have to implement the caret yourself.
But after migrating to React 18, I started seeing the caret disappear while typing, and while debugging it I discovered that React 18 handles events differently from what I had assumed.
In this post I want to share the shift in event handling flow that I learned by analyzing React 18's internal code directly, and how that shift actually affects rendering timing.
The Problem
To draw the caret, I need the actual height of the text element. For that, the caret component has to render after the text component has been committed.
To render the caret after the text component's commit, I intentionally deferred the caret component using a microtask.
queueMicrotask(() => {
// Render the caret after measuring the text element's size
cursorComponent();
});
After migrating from React 17 to React 18, the custom caret started disappearing whenever text was typed via the keyboard.
Tracking Down the Cause
When I compared the keyboard input event to the mouse click event, I noticed the following difference:
- Key input: text component re-renders → caret component doesn't render
- Mouse click: text component doesn't re-render → caret component renders correctly
At the moment the caret tried to render, the text DOM wasn't ready yet, so the height measurement failed and the rendering failed along with it.
What the debugging showed:
- The text component and the caret component were rendering inside the same microtask
- The caret ran first and referenced a text DOM that didn't exist yet → failure
In other words, on React 18 the text rendering itself was no longer happening immediately after the event — it was being deferred to a microtask — and because of that the old caret rendering approach stopped working.
The core of the problem, summarized
- On React 18, text component rendering is deferred to a microtask
- The caret component is still rendered inside a microtask
- The caret tries to render before the text DOM exists → render fails and the UX breaks
The concepts in the official docs — Automatic Batching, Concurrent Rendering — weren't enough on their own to explain the root cause. In the end I had to read React's internal code directly to understand what was going on.
Digging into the React 18 Updates
Concurrent Rendering
One of the biggest changes in React 18 is the introduction of Concurrent Rendering. Concurrent Rendering schedules multiple pieces of work concurrently and classifies them appropriately by priority, which makes the UI more responsive and lets React render the UI incrementally without blocking the browser.
The core problems React's Concurrent Rendering aims to solve are:
- Maintaining high responsiveness even during large-scale screen transitions
- Guaranteeing immediate response to user input
- Incremental rendering that doesn't block the browser
The Lane System and Priorities
React 18 uses a Lane system to manage the priority of work. Lanes assign a priority to each update, ensuring that important updates are processed first.
Lanes are implemented as bitmasks, and the further right a bit sits, the higher its priority.
// React 18 Lane definitions
const NoLanes = 0b0000000000000000000000000000000;
const NoLane = 0b0000000000000000000000000000000;
const SyncLane = 0b0000000000000000000000000000010;
const InputContinuousLane = 0b0000000000000000000000000001000;
const DefaultLane = 0b0000000000000000000000000100000;
const TransitionLanes = 0b0000000011111111111111110000000;
const TransitionLane1 = 0b0000000000000000000000010000000;
// ... up to TransitionLane16
const RetryLanes = 0b0000111100000000000000000000000;
const RetryLane1 = 0b0000000100000000000000000000000;
// ... up to RetryLane4Discrete Events and the Priority System
A Discrete Event is an event that originates from a user action and is fired as a discrete, individual occurrence. Because it comes from a user action, it has to react immediately, and it has the highest priority during scheduling.
DOM event classification react-dom/src/events/ReactDOMEventListener.js
// Determines priority per DOM event
function getEventPriority(domEventName) {
switch (domEventName) {
case 'click':
case 'input':
case 'keydown':
case 'keyup':
case 'submit':
return DiscreteEventPriority; // Sync Lane
case 'drag':
case 'scroll':
case 'mousemove':
case 'wheel':
return ContinuousEventPriority; // InputContinuous Lane
default:
return DefaultEventPriority; // Default Lane
}
}Mapping of event types to Lanes: react-reconciler/src/ReactFiberLane.js
| Event type | Example | Mapped Lane | Priority |
|---|---|---|---|
| Discrete | click, keydown, input | SyncLane | ⬆️ Very high |
| Continuous | scroll, mousemove, wheel | InputContinuousLane | ⬆️ Medium |
| Default | fetch, setTimeout and general work | DefaultLane | ➖ Normal |
| Transition | Page transitions, large state changes | TransitionLanes | ⬇️ Low |
The Sync Lane and How SyncQueue Is Processed
To guarantee an immediate response, updates triggered by Discrete Events are processed on the Sync Lane, and React uses an internal syncQueue instead of its general scheduler for them. The flow goes like this.
How a Discrete Event is processed:
-
Sync Lane assigned
The update that came from the Discrete Event is classified asSyncLane. -
Queued in syncQueue
performSyncWorkOnRoot, the synchronous rendering function, is registered insyncQueue.
// Queueing in syncQueue when processing a Discrete Event
function ensureRootIsScheduled(root, currentTime) {
const nextLanes = getNextLanes(root, NoLanes);
const newCallbackPriority = getHighestPriorityLane(nextLanes);
if (newCallbackPriority === SyncLane) {
// For a Sync Lane, register on the syncQueue
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
}
}- Render is performed
After that,flushSyncCallbacks()runs the callbacks queued insyncQueue, which does the following:- Renders every component whose state changed
- Commits the changed components
- Performs the DOM update
Through this priority system, React stays fast on the user's immediate inputs while deferring lower-priority updates appropriately to optimize overall performance.
The key point here is that in React 18, the timing at which this syncQueue is processed has changed.
React 17 vs 18: Comparing How syncQueue Is Processed
The timing at which callbacks added to the syncQueue are flushed changed between React 17 and 18. Related commit
React 17: processed immediately after the discrete event
- Register callback in syncQueue:
If a component's state changed during discrete event handling, the sync render callback
performSyncWorkOnRootis registered in syncQueue.
react-reconciler/src/SchedulerWithReactIntegration.new.js
export function scheduleSyncCallback(callback: SchedulerCallback) {
if (syncQueue === null) {
syncQueue = [callback]; // performSyncWorkOnRoot registered
} else {
syncQueue.push(callback);
}
}- Processed immediately
After discrete event handling,
flushSyncCallbackQueueis called right away.
react-reconciler/src/ReactFiberWorkLoop.old.js
export function discreteUpdates(fn, a, b, c, d) {
// ... handle the discrete event
try {
return runWithPriority(UserBlockingSchedulerPriority, fn.bind(null, a, b, c, d));
} finally {
if (executionContext === NoContext) {
flushSyncCallbackQueue(); // Run immediately!
}
}
}- Callback is executed
flushSyncCallbackQueueruns the callbackperformSyncWorkOnRootthat sits in syncQueue.
react-reconciler/src/SchedulerWithReactIntegration.new.js
function flushSyncCallbackQueueImpl() {
if (syncQueue !== null) {
const queue = syncQueue;
syncQueue = null; // Run callbacks queued in syncQueue
for (let i = 0; i < queue.length; i++) {
let callback = queue[i]; // performSyncWorkOnRoot
do {
callback = callback(true);
} while (callback !== null);
}
}
}React 18: syncQueue callbacks are processed in a microtask
- Register callback in syncQueue
If a component's state changed during discrete event handling, the sync render callback
performSyncWorkOnRootis registered in syncQueue (same as React 17).
react-reconciler/src/ReactFiberSyncTaskQueue.new.js
export function scheduleSyncCallback(callback: SchedulerCallback) {
if (syncQueue === null) {
syncQueue = [callback]; // performSyncWorkOnRoot registered
} else {
syncQueue.push(callback);
}
}- Schedule as a microtask
flushSyncCallbackQueue, which processes syncQueue callbacks, is scheduled as a microtask. Commit introducing the change
react-reconciler/src/ReactFiberWorkLoop.new.js
function ensureRootIsScheduled(root, currentTime) {
if (newCallbackPriority === SyncLane) {
// Register callback in syncQueue
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
if (supportsMicrotasks) {
// Scheduled as a microtask!
scheduleMicrotask(() => {
if (executionContext === NoContext) {
flushSyncCallbacks();
}
});
}
}
}- Not processed immediately
After discrete event handling,
flushSyncCallbackQueueis no longer called. Commit introducing the change
react-reconciler/src/ReactFiberWorkLoop.new.js
export function discreteUpdates(fn, a, b, c, d) {
try {
setCurrentUpdatePriority(DiscreteEventPriority);
return fn(a, b, c, d); // handle the discrete event
} finally {
setCurrentUpdatePriority(previousPriority);
// React 18: no flushSyncCallbackQueue call here!
}
}- Executed inside the microtask:
When the microtask runs, it calls the
flushSyncCallbackfunction registered in step [2] (renamed here), and that in turn runs the callback (performSyncWorkOnRoot) sitting in syncQueue (same as React 17).
react-reconciler/src/ReactFiberSyncTaskQueue.new.js
export function flushSyncCallbacks() {
if (syncQueue !== null) {
const queue = syncQueue;
syncQueue = null;
// Run the callbacks queued in syncQueue
for (let i = 0; i < queue.length; i++) {
let callback = queue[i]; // performSyncWorkOnRoot
do {
callback = callback(true);
} while (callback !== null);
}
}
}Key difference:
- React 17: renders immediately right after handling the discrete event
- React 18: renders inside a microtask after handling the discrete event
Because of this change, in React 18 even the synchronous rendering caused by Discrete Events (keyboard, click, etc.) runs inside a microtask. That's why the text component's rendering ends up deferred to a microtask in React 18 — and that's the direct cause of the caret rendering issue I described earlier.
How continuous events and default events are handled in React 18
In React 18, in addition to Discrete Events, there are Continuous, Default, and Idle events, which are classified under Lane priorities other than SyncLane and are processed asynchronously through the Scheduler.
- Registered with the Scheduler
performConcurrentWorkOnRootis scheduled as a macrotask. If it isn't aSyncLane, React calls the Scheduler'sscheduleCallback()with a priority based on the event type and registers the callback as a macrotask.
react-reconciler/src/ReactFiberWorkLoop.new.js
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
if (newCallbackPriority === SyncLane) {
// SyncLane handling
} else {
// Continuous / Default / Idle handling
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;
}
// Async scheduling through the Scheduler
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root), // scheduled as a macrotask
);
}
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}- Runs inside the macrotask
performConcurrentWorkOnRootruns from the Scheduler's task queue. The scheduled macrotask is invoked from the Scheduler queue and internally renders using Concurrent Mode.
react-reconciler/src/ReactFiberWorkLoop.new.js
function performConcurrentWorkOnRoot(root, didTimeout) {
// Execute rendering
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes) // yieldable rendering
: renderRootSync(root, lanes); // synchronous rendering
if (exitStatus !== RootInProgress) {
if (exitStatus !== RootDidNotComplete) {
// Render complete — get ready to commit
const finishedWork: Fiber = root.current.alternate;
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, lanes);
}
}
// Schedule the next work
ensureRootIsScheduled(root, now());
// Decide whether to continue running
if (root.callbackNode === originalCallbackNode) {
return performConcurrentWorkOnRoot.bind(null, root);
}
return null;
}Discrete vs Continuous/Default Events
| Aspect | Discrete Events | Continuous/Default Events |
|---|---|---|
| Lane | SyncLane | ContinuousLane / DefaultLane |
| Scheduling | scheduleSyncCallback → microtask | scheduleCallback → macrotask |
| Run function | performSyncWorkOnRoot | performConcurrentWorkOnRoot |
| Render style | Synchronous render (renderRootSync) | Time slicing (renderRootConcurrent) |
| Yieldable? | No | Yes |
| Batching | Batched inside the microtask | Auto-batching via the Scheduler |
Auto Batching
What Is Auto Batching?
Batching is a performance optimization where React groups multiple state updates into a single render. Up through React 17, batching only happened inside React event handlers, but in React 18 every update is batched automatically.
Limited Batching in React 17
In React 17, batching only worked inside React event handlers:
function handleClick() {
setCount(c => c + 1); // no re-render yet
setFlag(f => !f); // no re-render yet
// React batches these into a single re-render
}But it didn't batch inside async work like Promises or setTimeout:
function handleClick() {
fetchSomething().then(() => {
setCount(c => c + 1); // first re-render
setFlag(f => !f); // second re-render
});
}
setTimeout(() => {
setCount(c => c + 1); // first re-render
setFlag(f => !f); // second re-render
}, 1000);Auto Batching in React 18
When you use createRoot in React 18, batching applies automatically in every context — setTimeout, Promise, fetch, you name it.
function handleClick() {
fetchSomething().then(() => {
setCount(c => c + 1); // no re-render yet
setFlag(f => !f); // no re-render yet
// React batches these into a single re-render
});
}
setTimeout(() => {
setCount(c => c + 1); // no re-render yet
setFlag(f => !f); // no re-render yet
// React batches them within the same event loop tick
}, 1000);
Promise.resolve().then(() => {
setCount(c => c + 1); // no re-render yet
setFlag(f => !f); // no re-render yet
// React batches them within the same event loop tick
});How Auto Batching Works
The change that made Auto Batching possible is a change in how updates are processed:
1. Discrete Events (key input, click, etc.)
// React 17: immediate synchronous execution
function handleKeyDown() {
setState(...);
// → flushes immediately → renders right away
}
// React 18: deferred to a microtask
function handleKeyDown() {
setState(...);
// → schedules flushSyncCallbacks() as a microtask
// → renders after the event handler finishes
}2. Async updates (setTimeout, Promise, etc.)
// React 17: each one is a separate render
setTimeout(() => {
setA(1); // render 1
setB(2); // render 2
});
// React 18: batched into one
setTimeout(() => {
setA(1);
setB(2);
// handled as a single render
});How the React 18 Changes Affected Caret Rendering
The React 18 changes I covered above had a direct effect on the caret rendering logic. Now let me dig into how exactly concurrent rendering and Auto Batching caused the caret rendering issue.
The Core Problem: Render Timing Shift Reversed the Order
In React 17 the text component rendered synchronously and immediately, and the caret component was deferred via queueMicrotask, so the text → caret order was naturally guaranteed.
In React 18, though, the text component itself got deferred into a microtask, which meant the caret and the text ended up running in the same microtask — and as a result the caret ran before the text component had been committed, which is what broke things.
How key input is handled in React 17
function handleKeyDown(event) {
setText(newText); // 1. state update
// 2. flushSyncCallbacks() runs immediately
// 3. text render & commit completes
// 4. measure the text DOM
queueMicrotask(() => {
cursorComponent(textHeight); // 4. render the caret
});
}How key input is handled in React 18
function handleKeyDown(event) {
setText(newText); // 1. state update (added to syncQueue)
// 2. flushSyncCallbacks() → scheduled as a microtask
queueMicrotask(() => {
cursorComponent(textHeight); // 3. textHeight not available ❌
// 4. In the same microtask, flushSyncCallbacks() runs and renders the text
});
}The Combined Effect of Auto Batching and Concurrent Rendering
Two of React 18's key changes combined to produce the problem:
| Change | Role and impact |
|---|---|
| Concurrent Rendering | When handling a Discrete event, defers sync work into a microtask |
| Auto Batching | Groups updates inside the microtask into a single pass (delaying render timing) |
| → The result: text and caret end up rendering in the same tick, and the order gets tangled |
Step-by-Step Comparison
| Step | React 17 | React 18 |
|---|---|---|
| 1 | Key input event fires | Key input event fires |
| 2 | setState called → immediate sync render | setState called → microtask scheduled |
| 3 | Text DOM updated & measurable | Microtask queue waits |
| 4 | queueMicrotask: caret renders ✅ | Microtask: caret render attempt ❌ |
| 5 | - | Microtask: text DOM measured |
Execution Order in the Microtask Queue
In React 18, the microtasks run in this order:
[
() => cursorComponent(), // caret logic: queued first
() => flushSyncCallbacks(), // text render: added later by React
]
Why was the change made in the first place?
The change in React 18 was primarily about performance:
- Auto Batching: better performance by avoiding unnecessary re-renders
- Concurrent Rendering: better responsiveness by not blocking the main thread
- An unintended side effect: code that relied on the old render order now behaves differently
In React 18, when syncQueue handling for Discrete Events moved to a microtask, the old flow of "text render → measure text DOM → caret render" turned into "text render → caret render attempt → measure text DOM." That's what made the caret unable to compute its position and stop rendering correctly.
This is precisely how React 18's performance optimizations unintentionally affected my caret implementation.
How I Solved It
I tried a few approaches to fix the caret rendering issue under React 18.
First Try: Use a Macrotask
The first thing I tried was deferring the caret rendering into a macrotask via setTimeout.
function handleKeyDown(event) {
setText(newText); // text update
// DOM measurement done
setTimeout(() => { // run inside a macrotask
cursorComponent(textHeight); // render the caret
}, 0);
}The caret did render in the right position, but the UX wasn't great. When several characters were typed in quick succession, the caret looked like it was teleporting around.
The limitation of the macrotask approach is that macrotasks run too late in the event loop.
Analyzing the JavaScript Event Loop
To understand the difference between macrotasks and microtasks, I dug into the JavaScript event loop.
The basic flow of the event loop
console.log('1. sync');
queueMicrotask(() => console.log('2. microtask'));
setTimeout(() => console.log('4. macrotask'), 0);
queueMicrotask(() => console.log('3. microtask'));
// Output: 1 → 2 → 3 → 4The timing I actually needed was:
- After React finishes rendering
- but not as late as a macrotask
In other words, I needed an exact sweet spot between the microtask and the macrotask.
Second Try: Nested Microtasks
I came up with the idea of calling queueMicrotask inside another queueMicrotask, so the caret rendering would happen after React's flushSyncCallbacks.
function handleKeyDown(event) {
setText(newText); // text update
queueMicrotask(() => { // first microtask
queueMicrotask(() => { // second microtask
cursorComponent(); // render the caret
});
});
}How Auto Batching interacts with nested microtasks
// Check 1: microtasks at the same level are batched
queueMicrotask(() => {
setText("first text");
setText("second text"); // Auto Batching merges these
});
// Check 2: nested microtasks are batched separately
queueMicrotask(() => {
setText("outer text");
queueMicrotask(() => {
setText("inner text"); // handled as a separate batch
});
});- Microtasks at the same level get grouped into a single batch
- Nested microtasks are processed separately
→ which means the caret render can be safely deferred until after the text render
Carefully tuned timing that accounts for React 18's execution order:
// Execution order in React 18
handleKeyDown();
// 1. setText() called → scheduleMicrotask(flushSyncCallbacks)
// 2. queueMicrotask (first) registered
// 3. queueMicrotask (second) registered (inside the first)
// Microtask queue runs:
// → flushSyncCallbacks() (text render)
// → first microtask
// rendered together via auto batching
// → second microtask (caret render) ✅The takeaways from nested microtasks:
- Immediate response: the caret renders as soon as you type
- Accurate position: position is computed after the text DOM is ready
- Natural UX: smooth typing without the macrotask delay
Another Issue: Complexity in Collaborative Editing
// Event classification in collaborative editing
const updateFromOtherUser = (newText) => {
setText(newText); // a change coming from another user
// This isn't a Discrete Event — it's a Default Event (low priority)
};In collaborative editing, updates coming in from other users are handled internally by React as Default events. Render timing is deferred into a macrotask, so this case needs a different strategy.
| Event type | Lane priority | Render timing | Example |
|---|---|---|---|
| Discrete | SyncLane | Microtask | Key input, click |
| Default | DefaultLane | Macrotask | Network response, timer |
Final Strategy: Branch by Event Type
For collaborative editing, I had to use different strategies depending on the event type:
function handleTextUpdate(newText, source = 'user') {
setText(newText);
if (source === 'user') {
// Keyboard-driven: fast and accurate render
queueMicrotask(() => {
queueMicrotask(() => {
cursorComponent();
});
});
} else {
// Collaboration-driven: rely on the stable macrotask
setTimeout(() => {
cursorComponent();
}, 0);
}
}With React 18's shift in render timing, careful timing control became essential for the caret rendering logic.
- Macrotask is too slow → UX suffers
- Microtask is too fast → DOM not reflected
- Nested microtasks hit the sweet spot
- Cases with different event priorities — like collaborative editing — need separate handling → This is how I ended up with a stable approach for controlling caret rendering precisely and naturally.
What I Learned by Wrestling with React 18 Directly
When React 18 first introduced Auto Batching and Concurrent Rendering, I treated them as "something React handles internally to optimize things." The information I usually came across was just "React classifies events automatically and reorders work for performance."
But while actually dealing with the editor's caret rendering issue, I went straight to the internal code to understand how React classifies events and when rendering actually happens.
The things I specifically learned were:
- Discrete events (like keyboard input) run in microtasks,
while Default / Continuous and other events run as macrotasks - This behavior is determined by React's internal code (
scheduleSyncCallback,scheduleCallback) and by checks onexecutionContext - This change in handling directly affects when DOM rendering actually happens
- And I came to understand how microtasks and macrotasks flow through the event loop
None of this is something you can pick up just by reading the changelog. It only became clear after analyzing React's internal code. It was the kind of experience that let me viscerally understand how Concurrent Rendering actually handles events and defers rendering, and on what event-loop boundary Auto Batching operates — things I had only known as concepts before.
More than anything, it reminded me that React's "optimizations" don't always guarantee the result you intended in a complex project, and that taking the time to understand how things actually work internally really matters.
References https://goidle.github.io/react/in-depth-react18-lane/ https://goidle.github.io/react/in-depth-react18-concurrent_render/