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

"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
- 1. Why React setState Looks Asynchronous
- 2. How React Batching Works
- 3. Why Functional Updates in useState Seem Synchronous
- 4. useState Hook Implementation
- 5. setState Implementation
- 6. React Batching Update Mechanism
- 7. Concurrent Update Queue
- 8. Lane Priority System
- 9. Force Synchronous Updates — flushSync
- 10. Class Component setState Comparison
- 11. Key Differences Summary
- 12. Complete Call Chain
- Conclusion
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:
- Gets the dispatcher from the current renderer context through
resolveDispatcher()
- 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:
- Value update: action is the new value
- 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:
-
Connect queue.pending (newly queued) with baseQueue (historically left) head-to-tail into one ring
-
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)
-
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 withscheduleUpdateOnFiber
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:
-
Multiple updates within the same task merge into one render/commit
-
Event system still has batchedUpdates wrapper for historical/cross-renderer compatibility; modern scenarios mostly don't need manual wrapping
-
Batch ends (event/task end) uniformly finishEventHandler, schedule render
There are two benefits:
- Reduce render count, improve throughput
- 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 storagefinishQueueingConcurrentUpdates
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:
- React setState looks asynchronous because updates are queued and applied in unified commits
- React batching updates reduces render cycles and improves performance
- 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