How React's useState, Fragment, and Lazy Init Map Directly to Native DOM Behavior
Understanding the Complete Chain from Native DOM to React Lazy Initialization with useState
Today's learning path is very clear: the teacher first wrote the theoretical framework in the readme, then opened three files separately—starting from manually manipulating the DOM with native JS, and gradually transitioning to React's useState, Fragment, controlled components, and lazy initialization. This is not a scattered pile of knowledge points, but a complete, causally connected knowledge chain.
Teaching Panorama
readme0723.md (Design Blueprint)
├─→ test.html —— Native Control Experiment: DocumentFragment
├─→ App.jsx —— useState Basics: Closure Trap + Functional Update
└─→ App2.jsx —— Comprehensive Practice: Controlled Components + Lazy Initialization + Real-time Filtering
The teacher's approach today is: First, use native JS to let you experience "how troublesome manual DOM manipulation is," then use React to tell you "I've handled these troubles for you." Every React concept can find its counterpart in native JS, with old and new knowledge always welded together.
Phase 1: Readme Design—First Write Down Today's Teaching Skeleton
The teacher wrote two notes in readme0723.md, which form the complete skeleton of today's learning:
# useState
- Reactive data state
- The leader of hooks functional programming
- Parameter: initial value | function
- Return value: [state, update state]
## Fragment Component
- It can serve as a container, internally mounting child elements (DOM tree functionality)
- Mounted to the page #root in one go, the fragment element will then retire after achieving its purpose
These two notes are not casually written—each one is the guiding ideology for the subsequent code experiments:
- The four key points of
# useState: what it is (reactive data state), its status (the leader), input parameters (initial value or function), output parameters (value + setter) - The two key points of
## Fragment Component: container capability (mounting child elements), lifecycle (retires after achieving its purpose)
🤔 You might ask at this point: What is the rendering difference between Fragment and div? What does "retires after achieving its purpose" mean?
💡 Answer: div leaves a tangible node in the final DOM, Fragment does not. It's like a "temporary shipping container"—after transporting child elements to the page, it disappears. This design idea is in the same vein as native JS's DocumentFragment, as you'll see below.
Phase 2: test.html—The "Invisible Container" in the Native JS World
The first code file the teacher opened was test.html. This file underwent two version evolutions, each telling a key concept.
v1: Direct appendChild—Render Once Per Loop
<ul id="list"></ul>
<script>
const data = ['Task 1', 'Task 2', 'Task 3']
const olist = document.querySelector('#list')
for (const task of data) {
const item = document.createElement('li') // Create node in JS memory
item.innerText = task // Fill in text
olist.appendChild(item) // Attach to DOM tree → trigger rendering
}
</script>
Execution order walkthrough (the first key discussion point today):
document.querySelector('#list')— Native DOM API, uses CSS selector to get the empty<ul>on the pagedocument.createElement('li')— Browser's universal factory method, creates a<li>node in JS memory. Note: the node is not on the DOM tree at this point; the browser cannot see ititem.innerText = task— Fill text content into this in-memory nodeolist.appendChild(item)— Attach the node from memory to the DOM tree, this step triggers the browser's actual rendering. Three loops, three renders
The teacher planted a conceptual anchor here: createElement and appendChild must be understood separately. The former is just an object in JS memory, the latter is the operation that truly makes the browser "see". React's diff mechanism later is also built upon this "virtual vs. real" distinction.
v2: Batch Drawing with DocumentFragment—Three Loops, Only One Render
The teacher then improved the code:
<script>
const data = ['Task 1', 'Task 2', 'Task 3']
const olist = document.querySelector('#list')
// Document fragment can be a tag, has no entity, batch mount a group of elements in memory first
const fragment = document.createDocumentFragment()
for (const task of data) {
// Native DOM API creation
// Batch drawing
const item = document.createElement('li') // JS execution
item.innerText = task
// olist.appendChild(item) Page rendering, CSS drawing
fragment.appendChild(item) // Attach to fragment, no rendering!
}
// One-time, good performance
olist.appendChild(fragment)
</script>
The comments are teaching signposts left by the teacher, interpreted one by one:
| Comment | Teaching Meaning |
|---|---|
// Document fragment can be a tag, has no entity |
DocumentFragment has no corresponding node in the DOM |
// Batch mount a group of elements in memory first |
All operations are done in memory, no rendering triggered |
// Native DOM API creation |
createElement is a browser low-level API |
// Batch drawing |
Emphasizes "batch"—three loops, but rendering happens only once |
// olist.appendChild(item) Page rendering, CSS drawing |
The commented-out line is the code that truly triggers rendering |
// One-time, good performance |
The final single line completes all mounting |
The underlying flow of this code:
In the loop:
createElement → Create <li> in memory
innerText → Fill in text
fragment.appendChild → Attach to fragment (memory operation, browser invisible)
Fragment accumulates three <li>
Final step:
olist.appendChild(fragment)
→ Fragment hands over all three <li> inside it to <ul> in one go
→ Fragment itself disappears ("retires after achieving its purpose"!)
→ Browser renders one frame, user sees three tasks
This directly connects to the Fragment in the readme—the teacher first lets you understand the "entity-less container" design pattern in native JS, then tells you that React's <Fragment> follows the same idea. Comparing the two:
| DocumentFragment (Native) | <Fragment> (React) |
|
|---|---|---|
| Creation | document.createDocumentFragment() |
<Fragment> or <>...</> |
| Entity? | No, invisible in DOM | No, outputs no DOM node |
| Purpose | Batch operations to reduce reflow | Logical grouping without polluting DOM |
| After mount | Disappears | Disappears |
🤔 You might ask at this point: Why does
appendChildtrigger rendering? Isn't the thing created bycreateElementa DOM node?💡 Answer:
createElementreturns a real DOM node object—it has methods likeinnerText,style,appendChild, possessing all capabilities of<li>. But it is detached, not on the DOM tree. The DOM tree is the data structure the browser uses to draw the screen; only nodes attached to the tree are "painted". appendChild is the operation that inserts a detached node into the tree. This "memory vs. tree" distinction is the prerequisite for understanding React's virtual DOM diff later.
Phase 3: App.jsx—useState Basics and the Closure Trap
The teacher opened App.jsx, starting with the simplest counter to explain useState. This file underwent four code modifications, each advancing the understanding that "setState is asynchronous".
v1: The Most Basic Counter
import { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
const addCount = () => {
setCount(count + 1) // Modify state, asynchronous
console.log(count) // Synchronous 0
}
return (
<div>
<p>Current Count: {count}</p>
<button onClick={addCount}>+1</button>
</div>
)
}
Execution order walkthrough (user clicks button):
1. Click button → onClick triggers addCount()
2. setCount(count + 1) → Not executed immediately, update task placed in queue
3. console.log(count) → Prints the old count in the current closure (still 0)
4. addCount execution finishes
5. React processes queue → count becomes 1 → component re-renders
6. <p> changes from "Current Count: 0" to "Current Count: 1"
The key comments // Modify state, asynchronous and // Synchronous 0 convey the teacher's intention: setState is not an async function; its execution is essentially synchronous (enqueuing), but the state taking effect is asynchronous (waiting for re-render). So console.log executes synchronously after setCount, printing the old value.
v2: Why Does Three setCounts Only +1?
The teacher deliberately changed the code like this to create confusion:
const addCount = () => {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
After the user clicks, count only changes from 0 to 1, not 3.
Underlying reason—Here we delve into the hooks update queue for the first time:
Current render: count = 0 (constant, unchanged during function execution)
Three calls to setCount(count + 1) actually pass "value 1" to React:
{ action: 1 } → { action: 1 } → { action: 1 } (circular linked list)
React processes the queue:
newState = 0
1st: newState = 1
2nd: newState = 1 ← Overwrites
3rd: newState = 1 ← Overwrites again
Final = 1
What's passed is a value, each is setCount(1), the subsequent ones directly overwrite the previous—React thinks "it's still the same value, no need to recalculate."
v3: Change to Functional Update → +3
const addCount = () => {
setCount(prevCount => prevCount + 1)
setCount(prevCount => prevCount + 1)
setCount(prevCount => prevCount + 1)
}
This time, what's enqueued are functions:
{ action: prev => prev + 1 } → { action: prev => prev + 1 } → { action: prev => prev + 1 }
When processing the queue, React passes the previous step's result as prev:
newState = 0
1st: prev → 0+1 = 1
2nd: prev → 1+1 = 2
3rd: prev → 2+1 = 3
Final = 3
setCount(count + 1) |
setCount(prev => prev + 1) |
|
|---|---|---|
| Enqueued Content | Value 1 | Function prev => prev + 1 |
| Processing Method | Direct assignment, all identical | Execute function, prev increments step by step |
| Result of Three Calls | +1 | +3 |
🤔 You might ask at this point: How does setCount trigger a re-render? Where exactly is the count variable stored? Why is
countalways 0 during the threesetCount(count+1)calls?💡 Answer:
count is a constant in this render cycle. During the execution of
function App(),countis assigned a value (e.g., 0), and it won't change until this function finishes executing.setCountis not an assignment operation likecount = newValue, but a dispatch instruction—telling React: "Update this state to X in the next render." React puts this instruction into the update queue in the hooks linked list on the Fiber node, then schedules a re-render. The next timeApp()executes,useState(0)no longer takes the initial value 0, but reads the new value set last time from the hooks linked list.This is why
console.log(count)aftersetCountdefinitely prints the old value—it prints the count of this current render, while setCount affects the count of the next render. The two are not in the same time-space.
Phase 4: App2.jsx—Controlled Components + Lazy Initialization + Real-time Filtering
The teacher opened App2.jsx, the most complete example today, stringing together all previously discussed concepts. This file also underwent multiple iterations.
v1: Controlled Input + User Filtering
function App() {
const [users] = useState([
{ id: 1, name: 'Breeze' },
{ id: 2, name: 'Blitz' },
])
const [filterText, setFilterText] = useState('')
const filteredUsers = users.filter(user => user.name.includes(filterText))
return (
<div>
<h2>User List</h2>
<input
type="text"
placeholder="Enter username to filter"
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
<p>Currently showing {filteredUsers.length} users</p>
<ul>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
)
}
Line-by-line breakdown of <input>'s controlled mechanism:
<input
type="text" ← Fixed, HTML attribute
placeholder="Enter username to filter" ← Fixed, placeholder hint
value={filterText} ← Key to controlled: input display value always = state
onChange={e => setFilterText(e.target.value)} ← Fixed ↑ Synthetic event ↑ Self-named setter ↑ User input
/>
Event trigger sequence (the most detailed breakdown today):
User types "B"
↓
① Browser level: Sets input.value to "B" in memory, triggers native input event
(Screen hasn't refreshed yet, all operations in memory)
↓
② React captures native event → wraps into synthetic event e
(e.constructor.name === "SyntheticBaseEvent", not MouseEvent)
↓
③ onChange callback executes:
e.target points to the real <input> DOM node
e.target.value === "B"
setFilterText("B") → Update task enqueued
↓
④ React processes update: filterText changes from '' to 'B'
Component re-renders → value={filterText} sets "B" back to input (overriding native behavior)
↓
⑤ Browser's rendering phase for this frame → Screen displays "B"
Key detail: Steps ① and ④ are both in the JS execution phase of the same frame, before the browser has a chance to paint. React "intercepts" after the native setting, before the screen refresh—overwriting the value with the one in state. This is the essence of controlled components: the input has no autonomy, state has the final say.
🤔 You might ask at this point: Is
onChangea fixed event name? Isea native event? How to distinguish native events from synthetic events? Why cane.target.valueget the user input?💡 Answer:
onChangeis the synthetic event name defined by React; misspelling it means it won't work. It has been redefined by React—its behavior is equivalent to the nativeinputevent, triggering immediately on every content change (without waiting for blur)eis a SyntheticEvent wrapped by React, not a native MouseEvent/InputEvent. Criterion:e instanceof MouseEventis false (true for native),e.constructor.nameis"SyntheticBaseEvent"- JSX attribute bindings (
onClick={}) all yield synthetic events; only those bound viaaddEventListenerare native events- Synthetic events have a
.nativeEventproperty pointing to the original native event- Although
e.targetis a property of the synthetic event, it points to the real DOM node<input>. React doesn't proxy at this step, soe.target.valuegets the current value of that input in the browser
v2: Lazy Initialization—From 2 Test Data Items to 10,000 Simulated Data Items
The teacher added a heavyComputation function and replaced the hardcoded 2 users with 10,000 simulated data items:
function heavyComputation() {
console.log('Starting heavyComputation')
// Web performance optimization metric
const startTime = performance.now() // Current time
const result = []
for (let i = 0; i < 10000; i++) {
result.push({ id: i, name: `User-${i}` })
}
const duration = performance.now() - startTime // Execution time
console.log(duration)
return result
}
function App() {
// The initial value of the state is not given directly, it needs to be calculated
const [users] = useState(() => heavyComputation())
// ...
}
Here, two key writing styles are compared side-by-side:
// ❌ Pass value directly—heavyComputation() executes on every render
const [users] = useState(heavyComputation())
// heavyComputation() must finish executing at the parameter position first
// → Loop 10,000 times → Pass to useState
// → On re-render, React: I already have users in my linked list, discard this chunk 🗑️
// → Every keystroke by the user runs 10,000 loops for nothing
// ✅ Pass function—Executes only once on the first render
const [users] = useState(() => heavyComputation())
// Arrow function reference passed to useState, not executed
// → First render: React sees it's a function, calls it to get the initial value
// → Re-render: React takes the else branch, reads from hooks linked list, function not called
The underlying logic of lazy initialization (deep dive driven by questioning):
React maintains a hooks linked list on the Fiber node:
Fiber Node
└─ Hooks Linked List
┌──────────────────────────┐
│ [0] useState │ ← users stored here
│ memoizedState: [10000 items] │
│ queue: Update Queue │
└──────────────────────────┘
On each render, the internal logic of useState is simplified as follows:
if (is first render) {
if (typeof initialValue === 'function') {
hook.memoizedState = initialValue() // Call function to get initial value
} else {
hook.memoizedState = initialValue // Use the passed value directly
}
} else {
// Re-render: Read directly from linked list, initialValue parameter is completely ignored
// Your function never gets a chance to execute!
}
Parameters must be evaluated is dictated by JS syntax, React can't stop it. In useState(heavyComputation()), heavyComputation() finishes running before useState executes—like a dish cooked in advance, the waiter can only throw it away seeing the guest has left. useState(() => heavyComputation()) passes the menu, React decides whether to cook or not.
🤔 You might ask at this point: What if this lazy function produces different results each time it executes? Like
Math.random()?💡 Answer: The lazy function doesn't execute at all during re-renders, so the difference is meaningless—the result is always the value from the first render. If you want a value recalculated every time, you should use
useMemoor calculate it directly during rendering, not useState. useState's mission is to remember, not to regenerate.
v3: Derived Data—filteredUsers is not state, it's a computed property
// Data state state props computed computed property
const filteredUsers = users.filter(user => user.name.includes(filterText))
This comment line // computed computed property is important—filteredUsers is not state, it's a plain variable + real-time calculation:
Re-executes on every render:
users.filter(user => user.name.includes(filterText))
↑ ↑ ↑
Always those 10,000 Traverses each user Current input text
Protected by lazy, unchanged Gets new value on each render
users |
filteredUsers |
|
|---|---|---|
| Identity | state (protected by lazy init) | Derived data (recalculated each time) |
| Why designed this way | Data source shouldn't be recalculated | It depends on filterText, and should recalculate when filterText changes |
Lazy initialization only protects users from being regenerated, filteredUsers must re-filter every time—this is the correct behavior. If filteredUsers were also cached, the list wouldn't refresh when the input text changes, which would be broken.
Phase 5: Underlying Principles Summary
The teacher fully utilized questioning to push from "how to use the API" to "how the underlying implementation works":
1. How does setState trigger a re-render?
setCount(newValue)
→ Update task enqueued (hooks linked list's queue)
→ Mark Fiber node as "needs update"
→ React Scheduler schedules → Arrange re-render
→ App() re-executes → useState reads new value from linked list
→ New JSX → diff → DOM update
2. Order dependency of the hooks linked list
On each render, useState reads from the linked list in call order:
hooks[0] → useState(() => heavyComputation()) → users
hooks[1] → useState('') → filterText
The order must never change—this is why hooks cannot be placed inside if/for
3. Controlled Components vs Uncontrolled Components
Uncontrolled: Keyboard → input displays directly (value hidden in DOM)
Controlled: Keyboard → onChange → setState → value={state} → input displays
└── Value is in state, available anytime, no need to query DOM ──┘
The significance of controlled: state is the single source of truth. Want to get the input value? Read filterText. Want to filter? Use filterText directly. Want to submit? Grab filterText. No need for document.querySelector('input').value.
📝 Today's Learning Checklist
Review according to the teaching rhythm:
- Readme Design: Four elements of useState (reactive, leader, input/output params, lazy initialization), Fragment's container philosophy and "retires after achieving its purpose"
- Native Control Experiment (test.html): Manual createElement + appendChild → DocumentFragment batch rendering → Compare with React Fragment
- useState Basics (App.jsx): setState asynchronicity, closure trap, essential difference between passing value vs passing function, hooks linked list and update queue
- Comprehensive Practice (App2.jsx): Controlled component value/onChange loop, synthetic events vs native events, underlying principle of lazy initialization, derived data (computed properties)
- Underlying Principles: Read/write mechanism of hooks linked list, JS parameter evaluation order, Fiber update scheduling
🔄 Code Evolution Route
- test.html: v1 (direct appendChild, three renders) → v2 (DocumentFragment batch, one render)
- App.jsx: v1 (basic counter) → v2 (three setCounts only +1) → v3 (functional update +3) → v4 (underlying principle of passing value vs passing function)
- App2.jsx: v1 (2 data items + controlled filtering) → v2 (10,000 data items + lazy initialization) → v3 (derived data computed)
🎯 Today's Interview Questions
| Interview Question | Points to Article Section | Key Points for Answer |
|---|---|---|
"For this filter input, if a user quickly types 10 characters, how many times will heavyComputation run?" |
Phase 4 v2—Lazy Initialization | 0 times. useState(() => heavyComputation()) only calls the function on the first render; subsequent renders read directly from the hooks linked list. But users.filter() will run 10 times—it's derived data, not state, recalculated on every render. |
| "If you weren't using React, how many lines of code would this filter feature take? What's the key difference?" | Phase 2 test.html vs Phase 4 App2.jsx | Natively, you'd need manual querySelector, createElement, appendChild (also involving DOM clearing and rebuilding). The core difference isn't lines of code—with React, you just change the data, DOM updates are completely handled for you; natively, you must decide when to render and what to render yourself. |
"An interviewer sees setCount(count + 1) written three times in a row, asks what the result is and why at the low level?" |
Phase 3 v2/v3—Closure Trap + Update Queue | The result is only +1. The count in all three calls is the closure variable from the same render cycle, all passing the same value 1 to React. React traverses the update queue, and subsequent values directly overwrite previous ones. Functional update prev => prev + 1 is needed to pass the previous step's result to the next. |
#Frontend #React #useState #JavaScript #StudyNotes