跪拜 Guibai
← Back to the summary

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:

🤔 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):

  1. document.querySelector('#list') — Native DOM API, uses CSS selector to get the empty <ul> on the page
  2. document.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 it
  3. item.innerText = task — Fill text content into this in-memory node
  4. olist.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 appendChild trigger rendering? Isn't the thing created by createElement a DOM node?

💡 Answer: createElement returns a real DOM node object—it has methods like innerText, 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 count always 0 during the three setCount(count+1) calls?

💡 Answer:

count is a constant in this render cycle. During the execution of function App(), count is assigned a value (e.g., 0), and it won't change until this function finishes executing. setCount is not an assignment operation like count = 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 time App() 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) after setCount definitely 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 onChange a fixed event name? Is e a native event? How to distinguish native events from synthetic events? Why can e.target.value get the user input?

💡 Answer:

  • onChange is 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 native input event, triggering immediately on every content change (without waiting for blur)
  • e is a SyntheticEvent wrapped by React, not a native MouseEvent/InputEvent. Criterion: e instanceof MouseEvent is false (true for native), e.constructor.name is "SyntheticBaseEvent"
  • JSX attribute bindings (onClick={}) all yield synthetic events; only those bound via addEventListener are native events
  • Synthetic events have a .nativeEvent property pointing to the original native event
  • Although e.target is a property of the synthetic event, it points to the real DOM node <input>. React doesn't proxy at this step, so e.target.value gets 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 useMemo or 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 importantfilteredUsers 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:

🔄 Code Evolution Route


🎯 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