← Root to End Web dev track · 2 of 5 · checked against React 19.2.8 in Chromium on 720 programs

From a JSON file to the screen

The first page of the track set up a Vite + React project. Now our component needs data from public/Technologies.json. We load it three ways: plain JavaScript, useEffect with useState, and use(promise) with <Suspense>, and follow each one into React's source until we know which line runs when, and why the screen changes.

Web dev track: 1 · From the engine to the plugin · 2 · From a JSON file to the screen · next: 3 · From JSX to the DOM (planned), then 4 · From setState to the screen and 5 · From a URL to a Next.js page. Companion cheat sheet: How to write code in a .tsx file.

How this was checked: the dependency-array lab's model gives the same renders and effect runs as real React 19.2.8 on 720 click programs (every sequence of up to four clicks, plus seeded random ones, on four arrays), and both line-by-line labs match the order real React logs, screen by screen (verify/compare-with-react.js). Source excerpts are from React's v19.2.8 tag, fetched in October 2026.

Frame 0 · the root

A variable never moves a pixel

The browser draws the DOM, the tree of element objects it built from our HTML (the HTML to pixels page follows that tree to the screen). Our JavaScript variables live somewhere else entirely, in the engine's memory. Nothing connects them: count++ changes a number in memory, and the screen stays exactly as it was until some code writes to the DOM.

In plain JavaScript that code is ours. The script runs once, top to bottom, so a variable declared at the top keeps its value for the life of the page. A click handler runs again on every click, changes the variable, and then must also change the DOM element that shows it.

counter.js · plain JavaScripttwo separate jobs: remember, then show

  

What it is. Two counters. The left one is real plain JavaScript running on this page. The right one is a small imitation of React's cycle: a function that returns what the screen should show, called again after every change.

Checked against. The left side is the real browser. The right side is an illustration; real React's renders are checked in the dependency-array lab.

Not covered. How React compares the old and new output to change only the text that differs; that is reconciliation, on the planned page 4 of the track.

Plain JavaScript

Screen: 0

variable count = 0

React-style

Screen: 0

React's slot: count = 0

render 1: Counter() returned "clicked 0 times", committed

On one clickPlain JavaScriptReact
1. Remember the new valuecount++, a normal variable that lives as long as the pagesetCount(count + 1) puts an update on a slot React owns
2. Work out the new screenWe decide which element needs to changeReact calls our component again, and it returns new JSX
3. Change the DOMspan.textContent = count, written by usReact compares old and new JSX and changes only what differs (the commit)
4. DrawThe browser, by itselfThe browser, by itself

So plain JavaScript has no "re-render" at all, and no need for useState. Its variables never reset, because nothing calls the script again; and the screen changes only where we write to the DOM. Forget one write (tick the box above) and the screen is silently wrong. React exists to remove step 3 from our code: we change data, and React works out every DOM change.

Frame 1 · React calls our function again

useState: a slot that survives the next call

A React component is a function, and React calls it whenever it needs to know what the screen should look like. That call is a render. Changing the real DOM to match is the commit. Each render is a fresh function call, so let count = 0 inside the component is created again, set to 0, every time.

So the value has to live outside our function. React keeps one object per component instance, a fiber, and on it a list of hooks in call order. The first time useState runs, mountStateImpl makes a hook, stores our starting value in hook.memoizedState, and mountState returns it with a dispatch function bound to this fiber. That pair is the [count, setCount] we destructure. On later renders useState ignores the starting value and returns what the slot holds.

packages/react-reconciler/src/ReactFiberHooks.js · mountStateImpl(), mountState()trimmed (the dev-only double call of an initializer)

  

Calling setCount runs dispatchSetStateInternal. Usually it queues the update and calls scheduleUpdateOnFiber: that is the request to render again. But first it may try a shortcut. If nothing else is pending on this fiber or its alternate (the copy from the previous render), it computes the new state right away and compares it with Object.is. Equal means there is nothing to do, and React returns without scheduling a render.

packages/react-reconciler/src/ReactFiberHooks.js · dispatchSetStateInternal()trimmed to the eager comparison and the schedule

  

The shortcut has a condition, and real React shows it. Right after a real update, the old fiber still carries that update's lane, so the check at the top fails. The first setCount(sameValue) then does call our component once; React finds every state unchanged and throws that render away, with no commit and no effect. The next same-value call takes the shortcut. The dependency-array lab reproduces this exactly (try "count + 1", then "same value" twice), and the react.dev page warns that React "may still need to call your component before skipping the children".

Frame 2 · effects

useEffect is called now; our function runs after the commit

The line useEffect(() => { … }, []) holds two functions. useEffect itself is called during the render: the brackets after its name call it. Our arrow function is only passed to it as a value, like the function we pass to setTimeout(fn, 1000) or button.addEventListener("click", fn). Those calls run now and keep fn for later; useEffect does the same.

On the first render, mountEffectImpl receives our arrow as create and pushes it onto the fiber's effect list, flagged HookHasEffect ("run me"). There is no create() anywhere in it. After the commit has changed the DOM, commitHookEffectListMount walks that list and, for every effect with the flag, finally calls create(). That is the moment our fetch starts. Waiting until then means the page (here, the spinner) is already on screen, and React can check the dependency array on later renders before deciding whether to run the effect at all.

packages/react-reconciler/src/ReactFiberHooks.js · mountEffectImpl()verbatim

    
packages/react-reconciler/src/ReactFiberCommitEffects.js · commitHookEffectListMount()trimmed to the production branch

    

Lab: the useEffect version, line by line

This is the version of our Technologies section that keeps the data in useState and fetches in an effect (the full file is in Frame 5). Step through it. The lit lines are the ones running; on the right are the values React holds, the state of the effect, the network, and the screen.

What it is. The order in which React 19 runs this component, our effect and our fetch, with the screen at each step.

Checked against. verify/compare-with-react.js runs the same component on real React 19.2.8 (production build) with the server answering after 300 ms and after 800 ms. Real React logs exactly the steps marked "real React logs this", in this order, with the same screen at each one.

Not covered. StrictMode, which renders twice and runs the effect twice in development (see Real output), and a fetch that fails.

State in React's slots

Our effect

Network

Screen
Frame 3 · the dependency array

What goes in [ ], and when the effect runs again

On every render after the first, updateEffectImpl runs instead. If we passed an array, it hands this render's array and the previous one to areHookInputsEqual, which walks them item by item with is, React's name for Object.is. All equal: the effect is pushed without HookHasEffect, and the commit skips it. Anything different, or no array at all (nextDeps is null, so the comparison is never made): the flag is set and the effect runs after the commit.

So the array holds the values our effect reads: state, props, or anything computed from them. React's rule is that every such value belongs in it. Our fetch reads nothing that can change (a fixed file name), so [] is correct, and with an empty array the loop has nothing to compare and always answers "equal".

packages/react-reconciler/src/ReactFiberHooks.js · updateEffectImpl()verbatim

    
ReactFiberHooks.js · areHookInputsEqual() and packages/shared/objectIs.jstrimmed (development warnings)

    
We writeThe effect runsTypical use
useEffect(fn)After every committed renderRarely what we want
useEffect(fn, [])Once, after the first commitLoad data once, connect a listener once
useEffect(fn, [count])After the first commit, then after any render where count changeddocument.title = `Clicked ${count} times`; fetch again when a selected category changes
return () => { … } inside fnThe cleanup: before the next run, and when the component goes awayClear a timer, remove a listener, ignore a stale response

Lab: pick an array, then click

What it is. A component with two pieces of state, count and dark, and one effect whose array we choose. Each click is a state change; the table shows, for every moment, whether React rendered the component and whether the effect ran, and why.

Checked against. Real React 19.2.8, by verify/compare-with-react.js: every click sequence of up to four clicks and 60 seeded random longer ones, on all four arrays, 720 programs, all equal. The button below runs our clicks on real React in this browser.

Not covered. StrictMode's extra development runs, cleanup functions, and objects or functions in the array (a new object every render always counts as "changed").

The effect we are testing
#momentrendereffectwhy

Try [count], then "count + 1" (runs), "toggle dark" (render, effect skipped: dark is not in the array). Then no array, "count + 1", and "same value" twice: the first same-value click renders and is thrown away, so even an effect with no array does not run, and the second does not render at all (Frame 1 explains the lane check behind it).

Frame 4 · use() and Suspense

A component that throws, and a boundary that catches

use, added in React 19, reads a value from a promise during render. It first checks that it was given a "thenable" (an object with a .then method), then trackUsedThenable looks at a property React writes onto the promise itself: status. If it says "fulfilled", use returns value straight away. If the promise has no status yet, React sets it to "pending", attaches its own .then that will write status and value when the promise settles, and then throws SuspenseException. Our component stops on that line; the rest of it never runs.

The throw unwinds React's work loop up to the nearest <Suspense>, which renders its fallback instead, and React commits that. It also attaches a ping listener to the same promise. When the promise settles, pingSuspendedRoot marks the root and schedules a render. React then calls our component again from the top; this time status is "fulfilled", so use returns the data.

packages/react-reconciler/src/ReactFiberHooks.js · use()verbatim

    
packages/react-reconciler/src/ReactFiberWorkLoop.js · attachPingListener()trimmed (cache lookups, DevTools)

    
packages/react-reconciler/src/ReactFiberThenable.js · trackUsedThenable()trimmed to the status switch and the throw

  

Because React re-runs the component rather than resuming it, the promise must be the same object on the retry: only that object carries status: "fulfilled". In our file it is created once, at the top level, when the module first runs. If we wrote use(loadTechnologies()) inside the component, each retry would make a brand-new promise with no status, start another request, and suspend again, forever. Real React does exactly that: 22 requests in two seconds and the spinner never leaves (Real output). React's development warning "A component was suspended by an uncached promise" fires only when it re-runs the same render attempt and sees a different promise at the same position; in this loop each attempt is new, so we saw no warning at all.

One step real React takes that our first sketch missed: right after committing the fallback, React 19 renders the suspended part once more, while the promise is still pending, to warm up siblings it skipped during the unwind. use throws again and nothing on screen changes. The lab shows it as its own step, because real React logs it.

packages/react-reconciler/src/ReactFiberWorkLoop.js · module stateverbatim: the prewarm render

  

Lab: the use() + Suspense version, line by line

What it is. The order in which this file, React and the network run, with the promise's status and the screen at each step.

Checked against. Real React 19.2.8 (production build), server delays 300 ms and 800 ms: the steps marked "real React logs this" appear in real React's log in this order, with the same screen.

Not covered. StrictMode (it renders Technologies twice in development but still sends one request), errors (they need an error boundary), and server-side rendering.

technologiesPromise

Network

Screen
Frame 5 · our code

The Dev Stack Technologies section, both ways

Both files load public/Technologies.json (Vite serves public/ at / during development and copies it unchanged into dist/ when we build, so the same URL works after deploying). They show the same spinner, then the same grid. What differs is who does each job.

src/components/Technologies.tsx · use() + Suspensethe version we submit (card markup trimmed)

    
src/components/TechnologiesEffect.tsx · useEffect + useStatethe version we compare with (trimmed)

    
Jobuse() + SuspenseuseEffect + useStatePlain JavaScript
Start the fetch onceTop-level line, runs when the module loadsInside useEffect(…, []), after the first commitThe script runs once
Hold the dataThe promise (React writes status and value on it)useState slots on the fiberA normal variable
Know it is still loadingSuspense, automaticallyA loading state we keepWe track it ourselves
Show the spinnerfallback={…}loading ? spinner : gridInsert it, later remove it
Trigger the redrawReact's ping listener on the promiseOur setTechnologies and setLoadingWe write the DOM
Requests in development (StrictMode)12 (effects run twice in development only)1

Why async goes on a helper, never on the component or the effect. An async function always returns a promise. A component in the browser must return JSX, not a promise (React 19 throws "async Client Component" when it detects one), and an effect function may return only nothing or a cleanup function. So both versions put async on loadTechnologies and call it.

The blank page we hit while writing this. The file was public/Technologies.json, and the code fetched /technologies.json. Windows file names ignore case; URLs do not, and neither does Vite's list of public files or a Linux server like Netlify's. The server answered with a 404 page, res.json() failed on its first character ("Unexpected token '<', "<!doctype "... is not valid JSON"), the promise rejected, use threw that error, and with no error boundary to catch it React removed the whole tree. Real React does exactly this in our check. The name in fetch must match the file name exactly.

Which to submit: the use() + Suspense version. It has less code, fetches once even in development, and needs no loading state of our own. The useEffect version is the one most tutorials and existing projects use, so it is worth recognising, and react.dev points out its weak spots (race conditions, no caching) in "You Might Not Need an Effect".

Real output

React's own order, captured

Each line below was logged by real React 19.2.8 in Chromium running our two components, with the server answering after 300 ms. A MutationObserver adds a highlighted line each time a commit changed the screen. The production build is what npm run build ships; the development build with <StrictMode> is what npm run dev gives us.

What it is. Output of data/capture.js: our components written with React.createElement, served from a fake origin by Playwright so that fetch is a real network request we can delay and count.

Checked against. It is the real system; the verifier runs the same programs on every check.

Not covered. Timing (the order is what matters), other browsers, and Vite's dev server itself (we bundle the same packages with esbuild).

use() + Suspense · production
    useEffect + useState · production
      useEffect + useState · development, StrictMode

        The bug: a new promise in every render

          Frame 6 · practice

          Predict, then check

          Syntax questions while writing these components (where an if may go, ( ) or { } after =>, why onClick={f(x)} runs at once, props, keys, new arrays for state) are answered with live demos in the companion page How to write code in a .tsx file.

          See it on our own machine

          What to seeHow
          Renders and effects in orderconsole.log("render", loading) in the component body and console.log("effect") as the first line of the effect; reload and read the console
          StrictMode's double effectNetwork tab in development: Technologies.json twice in the useEffect version, once in the Suspense version; once each after npm run build and npm run preview
          The spinner for longerDevTools → Network → throttling "Slow 4G", then reload
          A suspended componentReact DevTools → Components: the Suspense node, and the "Suspend" toggle that forces its fallback
          The status React writesIn TechGrid, console.log(technologiesPromise) after use(): the promise object now has status: "fulfilled" and value

          Exercises

          1. In the dependency-array lab, choose [count, dark]. Predict the effect column for "toggle dark, same value, same value, count + 1" before clicking, then press "Check in this browser".
          2. Add useEffect(() => { document.title = `${count} selected`; }, [count]) to a counter. Which array would make the title go stale, and which would update it more often than needed?
          3. Change the useEffect version so that a failed fetch shows an error message instead of spinning forever. What has to change in state, and where does res.ok come in?
          4. Move use(technologiesPromise) from TechGrid into Technologies. Predict what disappears behind the spinner, then try it.
          5. Write the plain JavaScript version of the Technologies section with fetch, createElement and appendChild. Count the DOM operations it needs when one technology is added to the stack, and compare with one setStack call.
          Exam practice

          Exam-style questions, with model answers

          Five questions in the style of a web engineering exam. Write our answer first, then open the model answer and mark ourselves honestly. Orders and counts in the answers come from the verified labs and the captured real React output.

          Self-marks stay on this page only; refreshing clears them.
          For the useEffect version of the Technologies section (production build, no StrictMode), list in order: each render with the values of loading and technologies.length, each commit with what the screen shows, the effect run, and the fetch. How many renders, commits, effect runs and requests are there?

          Render 1: loading = true, 0 items. Commit: the spinner. Effect runs and sends the fetch. The response arrives, res.json() gives 12 items, setTechnologies(data) and setLoading(false) queue two updates for one render. Render 2: loading = false, 12 items. The effect is not run again ([] unchanged). Commit: the grid.

          Totals: 2 renders, 2 commits, 1 effect run, 1 request (the "useEffect + useState · production" log in Real output).

          Marking: 1 for render 1's values, 1 for "effect after the first commit", 1 for the fetch and response, 1 for one batched render, 1 for "effect skipped", 1 for the totals.

          A student writes fetch("/Technologies.json").then(r => r.json()).then(d => setTechnologies(d)) directly in the component body. The Network tab shows the request repeating forever. Explain why, naming the comparison React uses, and give two correct fixes.

          The component body runs on every render. Each response calls setTechnologies with a new array from JSON.parse; Object.is(oldArray, newArray) is false because they are different objects, so React schedules another render, which runs the fetch again: a loop.

          Fix 1: move the fetch into useEffect(…, []), which React runs once after the first commit. Fix 2: create the promise once at module level and read it with use(promise) inside a component wrapped in <Suspense>.

          Marking: 1 for "body runs every render", 1 for the new array, 1 for Object.is, 1 per fix.

          A component has state count and dark and useEffect(fn, [count]). After the mount, the user clicks: count + 1, toggle dark, setCount(count) (same value), setCount(count) again. For the mount and each click, say whether React 19.2.8 renders the component and whether the effect runs.

          Mount: renders, effect runs (always on the first commit). count + 1: renders, effect runs (count 0 → 1). toggle dark: renders, effect skipped (count unchanged). First same value: renders once and bails out, effect does not run (the previous update's lane was still on the fiber, so the eager comparison was not allowed). Second same value: no render at all (eager Object.is bail-out).

          Marking: 1 per moment; the third same-value mark needs the lane reason, not just the answer. (This is the lab's [count] array with those clicks; real React agrees.)

          Compare use(promise) with Suspense and useEffect with useState for loading data once: when the request starts, where the data lives, what triggers the second render, and how many requests the development build sends under StrictMode.

          use + Suspense: the request starts when the module runs (before any render); the data lives on the promise as status/value; React's ping listener on the promise schedules the retry; 1 request in development. useEffect + useState: the request starts after the first commit; the data lives in state slots on the fiber; our setTechnologies/setLoading schedule the render; 2 requests in development because StrictMode runs effects twice (1 in production).

          Marking: 1 for each of the four points, both sides needed.

          Write a short note on why the promise given to use() must be created outside the component that reads it.

          When use meets a pending promise, React tags it status: "pending", attaches listeners that will write status and value, and throws to the nearest Suspense. When the promise settles, React renders the component again from the top; it does not resume it. Only the original promise object carries "fulfilled", so the retry must receive the same object. A promise created inside the component is new on every render, has no status, starts a new request, and suspends again forever (22 requests in 2 s in our capture). Creating it once at module level, or in a cache or a framework, gives every render the same object.

          Marking: 1 for the status written on the object, 1 for "re-run, not resume", 1 for the infinite loop, 1 for where to create it.

          Reading list

          Everything this page cites, by depth

          The numbered markers in the text open the relevant reading right where we need it. Here is all of it in one place: Start here for a first pass, Go deeper for the full treatment, and Primary sources for React's own reference pages.

          Sources

          Original files and data

          React is MIT licensed, Copyright (c) Meta Platforms, Inc. and affiliates; excerpts are quoted for study, and the bundle in vendor/ keeps React's licence notices.