18 min read

Deep Dive into React setState and Batching (React 19 Source Code Analysis)

Author
Luxing Li
@luxingli

"I clearly called set, but console.log still shows the old value?" - I think every React beginner has encountered this pitfall.

This article will explain the reasons directly from the source code. We will follow the call chain: from when you trigger setState (setX) to the final rendering, explaining three questions - why it appears "asynchronous", how batching works, and why functional updates make the result "seem synchronous".

Table of Contents

TL;DR

React setState looks asynchronous because React queues updates and applies them in a unified commit. React batching updates is handled by the Fiber + Lane system to ensure performance and consistency. Functional updates in useState ensure correct accumulation, but DOM commit always happens in one flush, not synchronously.


1. Why React setState Looks Asynchronous

React setState doesn't immediately change the value in the current render snapshot. Instead, it:

  • Puts updates into the Hook queue
  • Marks priority (lane)
  • Hands them to the scheduler to calculate and commit uniformly in the next render phase

Therefore, console.log in the same round still reads the old snapshot.

2. How React Batching Works

React batching updates means multiple updates in the same batch will be merged, triggering only one render/commit. Key points:

  • Priority is controlled by lanes
  • Use flushSync explicitly when immediate flushing is needed
  • Reduces render count and improves performance

3. Why Functional Updates in useState Seem Synchronous

Functional updates in useState have special behavior:

  • Hooks don't have the second callback parameter of class components
  • setX(prev => next) ensures each calculation gets the value after the previous queue is applied
  • This makes multiple accumulations correct

However, committing to DOM still happens in that unified commit, not synchronously.


First, let's find the useState Hook implementation code

4. useState Hook Implementation

4.1 Entry Point — resolveDispatcher and Decoupling

File: packages/react/src/ReactHooks.js

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

useState doesn't directly implement logic. Instead, it:

  1. Gets the dispatcher from the current renderer context through resolveDispatcher()
  2. Calls dispatcher.useState

This approach provides several benefits:

  • Decoupling: React core doesn't care "where to render", dispatch to corresponding implementation
  • Phase distinction: mountState when mounting, updateState when updating, decided by dispatcher

4.2 Hook Queue Structure — Hook and UpdateQueue

File: packages/react-reconciler/src/ReactFiberHooks.js

Hook Object Structure

export type Hook = {
  memoizedState: any,      // Current state value
  baseState: any,          // Base state (for rebase)
  baseQueue: Update<any, any> | null,  // Base update queue
  queue: any,              // Update queue
  next: Hook | null,       // Next Hook
};

UpdateQueue Structure

export type UpdateQueue<S, A> = {
  pending: Update<S, A> | null,    // Pending updates (circular linked list)
  lanes: Lanes,                    // Update priority
  dispatch: (A => mixed) | null,   // Dispatch function
  lastRenderedReducer: ((S, A) => S) | null,  // Last rendered reducer
  lastRenderedState: S | null,     // Last rendered state
};

Key information here:

  • Hook.memoizedState: Latest state of this Hook (used in this render)
  • baseState/baseQueue: Used to replay low-priority skipped updates (rebase)
  • queue: Update queue; pending is the tail pointer of circular linked list
  • lastRenderedReducer/State: Reducer and state used in last successful render, for eager state and comparison

The benefits of this approach:

  • Circular linked list makes it easy to merge updates from multiple sources to the same queue tail (O(1) concatenation)
  • baseQueue allows low-priority updates to skip current round and be preserved for next round replay, ensuring consistency

For example, in one click handler you trigger A (default priority) and B (transition priority) two types of updates. This round only processes A, B will be cloned to baseQueue, waiting for next round when renderLanes is satisfied to calculate.

4.3 mountState Implementation — Initialization and Stable Dispatch

Hook initialization on first render:

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  // Handle functional initial state
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    initialState = initialStateInitializer();
    // Double call in Strict Mode
    if (shouldDoubleInvokeUserFnsInHooksDEV) {
      setIsStrictModeForDevtools(true);
      try {
        initialStateInitializer();
      } finally {
        setIsStrictModeForDevtools(false);
      }
    }
  }
  
  // Initialize Hook state
  hook.memoizedState = hook.baseState = initialState;
  
  // Create update queue
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  
  // Create dispatch function, bound to fiber and queue
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  
  return [hook.memoizedState, dispatch];
}

Purpose of this code:

  • Support lazy initial value: useState(() => expensiveInit()) only calls function on first call
  • (DEV) In StrictMode, initial function is called twice for side effect detection
  • Initialize hook.memoizedState/baseState, build queue, and create stable dispatch (bound to current fiber + queue)

This allows lazy initialization to avoid unnecessary calculation, and makes dispatch stable (reference doesn't change). Users won't cause unnecessary re-renders of child components due to function identity changes.

4.4 updateState Implementation — Unified through updateReducer

State handling during updates:

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState);
}

updateState is actually a thin wrapper around updateReducer(basicStateReducer, initialState).

basicStateReducer rules:

  1. Value update: action is the new value
  2. Functional update: action(prev) => next, the benefit is using the same reducer flow to handle useState and useReducer, simplifying implementation

For example:

setCount(5)          // Value update
setCount(c => c + 1) // Functional update

Both go through basicStateReducer, just different action forms.

4.5 updateReducer — Merge Queue, Replay by Priority

function updateReducerImpl<S, A>(
  hook: Hook,
  current: Hook,
  reducer: (S, A) => S,
): [S, Dispatch<A>] {
  const queue = hook.queue;
  queue.lastRenderedReducer = reducer;

  // Get base queue and pending queue
  let baseQueue = hook.baseQueue;
  const pendingQueue = queue.pending;
  
  if (pendingQueue !== null) {
    // Merge pending queue to base queue
    if (baseQueue !== null) {
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  const baseState = hook.baseState;
  if (baseQueue === null) {
    // No pending updates, use base state directly
    hook.memoizedState = baseState;
  } else {
    // Process update queue
    const first = baseQueue.next;
    let newState = baseState;
    let update = first;
    
    do {
      const updateLane = removeLanes(update.lane, OffscreenLane);
      const shouldSkipUpdate = !isSubsetOfLanes(renderLanes, updateLane);
      
      if (shouldSkipUpdate) {
        // Insufficient priority, skip this update
        const clone: Update<S, A> = {
          lane: updateLane,
          revertLane: update.revertLane,
          gesture: update.gesture,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: (null: any),
        };
        // Add skipped update to new base queue
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
      } else {
        // Apply this update
        if (update.hasEagerState) {
          // Use pre-calculated state
          newState = update.eagerState;
        } else {
          // Call reducer to calculate new state
          newState = reducer(newState, update.action);
        }
      }
      update = update.next;
    } while (update !== null && update !== first);
    
    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;
  }
  
  return [hook.memoizedState, queue.dispatch];
}

What this code does:

  1. Connect queue.pending (newly queued) with baseQueue (historically left) head-to-tail into one ring

  2. Replay updates in sequence:

    • If update.lane is not in current renderLanes, skip and clone to new baseQueue
    • Otherwise calculate new state with eagerState or reducer(prev, action)
  3. After completion, write back memoizedState, and new baseState/baseQueue

Why do this:

  • Priority-aware: Low priority doesn't block current render
  • Don't lose updates: Skipped ones go back to baseQueue, calculated in future
  • Consistent order: Replay in queue order, functional updates behave correctly

For example:

startTransition(() => setA(1)); // Low priority
setB(1);                        // Default priority
// This round might only apply setB, setA gets cloned to baseQueue, calculated next round

5. setState Implementation

5.1 dispatchSetState Core Logic — Choose Lane, Initiate Scheduling

File: packages/react-reconciler/src/ReactFiberHooks.js

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  // Development environment check callback parameters
  if (__DEV__) {
    const args = arguments;
    if (typeof args[3] === 'function') {
      console.error(
        "State updates from the useState() and useReducer() Hooks don't support the " +
          'second callback argument. To execute a side effect after ' +
          'rendering, declare it in the component body with useEffect().',
      );
    }
  }

  // Request update priority
  const lane = requestUpdateLane(fiber);
  
  // Internal dispatch handling
  const didScheduleUpdate = dispatchSetStateInternal(
    fiber,
    queue,
    action,
    lane,
  );
  
  if (didScheduleUpdate) {
    startUpdateTimerByLane(lane, 'setState()');
  }
  markUpdateInDevTools(fiber, lane, action);
}

This code handles:

  • Development environment prevents "second callback parameter"
  • Choose lane through requestUpdateLane(fiber) (discrete/continuous/default/transition)
  • Go through dispatchSetStateInternal, schedule render with scheduleUpdateOnFiber when necessary

This allows lanes to give updates from different sources different urgency.

And separate "record update" from "schedule render", convenient for merging/interruption.

For a simple example:

Updates in click events are usually SyncLane (discrete event priority), while updates from startTransition go through TransitionLanes, the latter allowing smoother UI.

5.2 Eager State Optimization — Eager State and Quick Skip

Pre-calculate state optimization:

function dispatchSetStateInternal<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
  lane: Lane,
): boolean {
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    gesture: null,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    // Render phase update
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // Queue is empty, can pre-calculate state
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          
          // Cache pre-calculated state
          update.hasEagerState = true;
          update.eagerState = eagerState;
          
          if (is(eagerState, currentState)) {
            // States are the same, can skip render directly
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return false;
          }
        } catch (error) {
          // Ignore error, re-throw in render phase
        }
      }
    }

    // Normal update scheduling
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
      return true;
    }
  }
  return false;
}

This code makes a judgment:

If it's a fast path: When fiber and its alternate have no pending lanes, try to directly calculate next state (eager) with lastRenderedReducer/State; if equal to current, go through enqueueConcurrentHookUpdateAndEagerlyBailout, skip scheduling.

Otherwise, normally enter concurrent queue, mark root, schedule.

This can avoid "invalid updates" triggering render (like setX(x)).

For a simple example:

setCount(c => c) // After calculation equals current, might skip render directly

6. React Batching Update Mechanism

6.1 Batching Entry

File: packages/react-dom-bindings/src/events/ReactDOMUpdateBatching.js

export function batchedUpdates(fn, a, b) {
  if (isInsideEventHandler) {
    // Already in batching, execute directly
    return fn(a, b);
  }
  isInsideEventHandler = true;
  try {
    return batchedUpdatesImpl(fn, a, b);
  } finally {
    isInsideEventHandler = false;
    finishEventHandler();
  }
}

6.2 Batching in Event System

File: packages/react-dom-bindings/src/events/DOMPluginEventSystem.js

// Event handling wrapped in batchedUpdates
batchedUpdates(() =>
  dispatchEventsForPlugins(
    domEventName,
    eventSystemFlags,
    nativeEvent,
    ancestorInst,
    targetContainer,
  ),
);

6.3 Event Dispatch Handling

function processDispatchQueueItemsInOrder(
  event: ReactSyntheticEvent,
  dispatchListeners: Array<DispatchListener>,
  inCapturePhase: boolean,
): void {
  let previousInstance;
  if (inCapturePhase) {
    // Capture phase: from back to front
    for (let i = dispatchListeners.length - 1; i >= 0; i--) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  } else {
    // Bubble phase: from front to back
    for (let i = 0; i < dispatchListeners.length; i++) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  }
}

function executeDispatch(
  event: ReactSyntheticEvent,
  listener: Function,
  currentTarget: EventTarget,
): void {
  event.currentTarget = currentTarget;
  try {
    listener(event); // Execute event handler, may trigger setState
  } catch (error) {
    reportGlobalError(error);
  }
  event.currentTarget = null;
}

This code mainly implements three points:

  1. Multiple updates within the same task merge into one render/commit

  2. Event system still has batchedUpdates wrapper for historical/cross-renderer compatibility; modern scenarios mostly don't need manual wrapping

  3. Batch ends (event/task end) uniformly finishEventHandler, schedule render

There are two benefits:

  1. Reduce render count, improve throughput
  2. Combine with lanes, ensure interaction priority response

For example:

setA(1); setB(1); // Same click/same microtask, multiple sets merge into one render

Need immediate flush then use flushSync(() => setA(1)).

7. Concurrent Update Queue

7.1 Update Enqueuing

File: packages/react-reconciler/src/ReactFiberConcurrentUpdates.js

export function enqueueConcurrentHookUpdate<S, A>(
  fiber: Fiber,
  queue: HookQueue<S, A>,
  update: HookUpdate<S, A>,
  lane: Lane,
): FiberRoot | null {
  const concurrentQueue: ConcurrentQueue = (queue: any);
  const concurrentUpdate: ConcurrentUpdate = (update: any);
  enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane);
  return getRootForUpdatedFiber(fiber);
}

function enqueueUpdate(
  fiber: Fiber,
  queue: ConcurrentQueue | null,
  update: ConcurrentUpdate | null,
  lane: Lane,
) {
  // Add update to concurrent queue
  concurrentQueues[concurrentQueuesIndex++] = fiber;
  concurrentQueues[concurrentQueuesIndex++] = queue;
  concurrentQueues[concurrentQueuesIndex++] = update;
  concurrentQueues[concurrentQueuesIndex++] = lane;

  concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);

  // Immediately update fiber's lanes
  fiber.lanes = mergeLanes(fiber.lanes, lane);
  const alternate = fiber.alternate;
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
}

7.2 Queue Processing

export function finishQueueingConcurrentUpdates(): void {
  const endIndex = concurrentQueuesIndex;
  concurrentQueuesIndex = 0;
  concurrentlyUpdatedLanes = NoLanes;

  let i = 0;
  while (i < endIndex) {
    const fiber: Fiber = concurrentQueues[i];
    concurrentQueues[i++] = null;
    const queue: ConcurrentQueue = concurrentQueues[i];
    concurrentQueues[i++] = null;
    const update: ConcurrentUpdate = concurrentQueues[i];
    concurrentQueues[i++] = null;
    const lane: Lane = concurrentQueues[i];
    concurrentQueues[i++] = null;

    if (queue !== null && update !== null) {
      // Add update to queue's circular linked list
      const pending = queue.pending;
      if (pending === null) {
        update.next = update;
      } else {
        update.next = pending.next;
        pending.next = update;
      }
      queue.pending = update;
    }

    if (lane !== NoLane) {
      markUpdateLaneFromFiberToRoot(fiber, update, lane);
    }
  }
}

There are two functions here:

  • enqueueConcurrentHookUpdate just puts (fiber, queue, update, lane) into concurrentQueues for temporary storage
  • finishQueueingConcurrentUpdates uniformly replays to each Hook's circular queue (queue.pending), and marks lanes up to root

This allows collecting updates from multiple fibers in one event/task, then distribute back to respective queues at once, reducing lock contention and state dispersion.

For example to help understand: In one event you simultaneously setState parent and child components, both updates first enter concurrentQueues, then get distributed back to two Hook's pending at once.

8. Lane Priority System

8.1 Lane Definitions

File: packages/react-reconciler/src/ReactFiberLane.js

export const TotalLanes = 31;

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000;
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000;
export const TransitionLanes: Lanes = /*               */ 0b0000000001111111111111100000000;

8.2 Priority Request

File: packages/react-reconciler/src/ReactFiberWorkLoop.js

export function requestUpdateLane(fiber: Fiber): Lane {
  const mode = fiber.mode;
  if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
    return (SyncLane: Lane);
  } else if (
    (executionContext & RenderContext) !== NoContext &&
    workInProgressRootRenderLanes !== NoLanes
  ) {
    // Render phase update
    return pickArbitraryLane(workInProgressRootRenderLanes);
  }

  const transition = requestCurrentTransition();
  if (transition !== null) {
    return requestTransitionLane(transition);
  }

  return eventPriorityToLane(resolveUpdatePriority());
}

8.3 Update Scheduling

export function scheduleUpdateOnFiber(
  root: FiberRoot,
  fiber: Fiber,
  lane: Lane,
) {
  // Mark root has pending updates
  markRootUpdated(root, lane);

  if (
    (executionContext & RenderContext) !== NoContext &&
    root === workInProgressRoot
  ) {
    // Render phase update
    workInProgressRootRenderPhaseUpdatedLanes = mergeLanes(
      workInProgressRootRenderPhaseUpdatedLanes,
      lane,
    );
  } else {
    // Normal update scheduling
    if (root === workInProgressRoot) {
      // Current rendering tree receives update
      if ((executionContext & RenderContext) === NoContext) {
        workInProgressRootInterleavedUpdatedLanes = mergeLanes(
          workInProgressRootInterleavedUpdatedLanes,
          lane,
        );
      }
    }
    
    // Ensure root is scheduled
    ensureRootIsScheduled(root);
  }
}

The purpose of this code is:

  • Using bitmask to express 31 lanes (SyncLane/DefaultLane/TransitionLanes...)
  • requestUpdateLane chooses lane based on event priority and current context
  • scheduleUpdateOnFiber/ensureRootIsScheduled decides when and in what mode to run performConcurrentWorkOnRoot

This can be more granular than "sync/async", and allows interruption and resumption (concurrent), do important things first.

For example:

Click triggers setCount (SyncLane) + simultaneous startTransition list update (TransitionLanes): Count will update and render first, list update can be delayed, avoiding stuttering.

9. Force Synchronous Updates — flushSync

9.1 flushSync Implementation

File: packages/react-dom/src/shared/ReactDOMFlushSync.js

function flushSyncImpl<R>(fn: (() => R) | void): R | void {
  const previousTransition = ReactSharedInternals.T;
  const previousUpdatePriority = ReactDOMSharedInternals.p;

  try {
    ReactSharedInternals.T = null;
    ReactDOMSharedInternals.p = DiscreteEventPriority;
    
    if (fn) {
      return fn();
    } else {
      return undefined;
    }
  } finally {
    ReactSharedInternals.T = previousTransition;
    ReactDOMSharedInternals.p = previousUpdatePriority;
    
    const wasInRender = ReactDOMSharedInternals.d.f();
    if (__DEV__) {
      if (wasInRender) {
        console.error(
          'flushSync was called from inside a lifecycle method. React cannot ' +
          'flush when React is already rendering. Consider moving this call to ' +
          'a scheduler task or micro task.',
        );
      }
    }
  }
}

This code temporarily raises update priority to discrete event level, flush immediately; commonly used when need to read DOM layout immediately then calculate next step.

This is because some integrations (measure width/height, scroll immediately) indeed need "render first → read → render again".

Note that flushSync will force one synchronous flush (commit) at callback end, thus interrupting current auto-batching cycle; but multiple sets within callback will still be merged into this one synchronous commit.

10. Class Component setState Comparison

10.1 Class Component setState Implementation

File: packages/react-reconciler/src/ReactFiberClassComponent.js

const classComponentUpdater = {
  enqueueSetState(inst: any, payload: any, callback) {
    const fiber = getInstance(inst);
    const lane = requestUpdateLane(fiber);

    const update = createUpdate(lane);
    update.payload = payload;
    
    // Support callback function (Hooks don't support)
    if (callback !== undefined && callback !== null) {
      if (__DEV__) {
        warnOnInvalidCallback(callback);
      }
      update.callback = callback;
    }

    const root = enqueueUpdate(fiber, update, lane);
    if (root !== null) {
      startUpdateTimerByLane(lane, 'this.setState()');
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitions(root, fiber, lane);
    }
  },
};

10.2 Class Component Update Queue

File: packages/react-reconciler/src/ReactFiberClassUpdateQueue.js

export function enqueueUpdate<State>(
  fiber: Fiber,
  update: Update<State>,
  lane: Lane,
): FiberRoot | null {
  const updateQueue = fiber.updateQueue;
  const sharedQueue: SharedQueue<State> = (updateQueue: any).shared;

  if (isUnsafeClassRenderPhaseUpdate(fiber)) {
    // Unsafe render phase update
    const pending = sharedQueue.pending;
    if (pending === null) {
      update.next = update;
    } else {
      update.next = pending.next;
      pending.next = update;
    }
    sharedQueue.pending = update;
    return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane);
  } else {
    // Normal concurrent update
    return enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane);
  }
}

It can be seen that class component queue structure is similar to Hooks, but setState(updater, callback) supports post-commit callback. Hooks don't support this "second parameter", corresponding semantics go to useEffect/useLayoutEffect.

This way, function components unify "post-commit side effects" into Effect system, avoiding two parallel mechanisms.

For example:

// Class component
this.setState({a:1}, () => console.log('committed'));

// Function component
setA(1);
useEffect(() => { console.log('committed'); }, [a]);

11. Key Differences Summary

  • Functional updates in useState ensure correct accumulation, but don't make updates "synchronous"
  • React batching updates enabled by default; use flushSync when immediate flush needed
  • Low-priority updates will be skipped and stored in baseQueue, replayed in future
  • Eager state can directly calculate "no change" when queue is empty, skip render

12. Complete Call Chain

Complete flow:

This complete implementation chain shows how React achieves efficient state management and batched updates through Fiber architecture, Lane priority system, and concurrent features.

Conclusion

Understanding React setState asynchronous behavior, React batching updates, and functional updates in useState is crucial for writing performant React applications. The key takeaways are:

  1. React setState looks asynchronous because updates are queued and applied in unified commits
  2. React batching updates reduces render cycles and improves performance
  3. Functional updates in useState ensure correct state accumulation while maintaining the batching benefits

The Fiber architecture and Lane priority system work together to provide a sophisticated update mechanism that balances performance, consistency, and developer experience.

Afterword

I hope this article has been helpful to you.
If you’d like to discuss technical questions or exchange ideas, feel free to reach out: luxingg.li@gmail.com