Things, elements, fibers, pixels
React has four names that beginners treat as interchangeable: ReactNode, ReactElement, Component, and plain tag names like div. They look like they mean roughly the same thing. They don't. Sorting them out clarifies what lives in JS memory, what lands in the DOM, and what gets called when.
Four kinds, one sentence each
| Name | What it is | Real or logical |
|---|---|---|
| ReactNode | A TypeScript type. The widest bucket: anything React can render. | Logical |
| ReactElement | A plain JS object produced by JSX or createElement. Shape: { type, props, key, ref }. | Logical |
| Component | A function or class. Takes props, returns elements. | Logical (it's code) |
div | A string. An HTML tag name. | Logical as a string. Becomes a real HTMLDivElement after commit. |
Everything except the final DOM output lives in JS memory. Only the DOM node that React commits into the browser is real.
How they stack
The four are not on the same axis. Some are hierarchical, some are peer, one is a producer of another.
ReactNode ⊃ ReactElement. ReactNode is the widest type. It includes everything React will render: ReactElement, strings, numbers, null, undefined, false, arrays of ReactNodes, Fragments. ReactElement is just one kind.
// All of these are ReactNode. Only the first is ReactElement.
<div /> // ReactElement
'hello' // string
42 // number
null
[<a/>, <b/>] // array
<>...</> // FragmentComponent and 'div' are peers. Look at the shape of a ReactElement:
{ type, props, key, ref }The type field can be either a string like 'div' (a host component) or a Component (a function or class). Same slot, two valid inhabitants. Neither is a subcategory of the other.
<div>hi</div> // type: 'div' (string, host)
<Button>hi</Button> // type: Button (function, user)React inspects type during reconciliation:
- String type: prepare a real DOM node at commit.
- Function or class: call it, expand into more elements.
Component produces ReactElements. A component is a function. You call it (or write <Name />) and it returns one or more ReactElements. The component itself never appears on screen. What appears are the elements it produces.
function Button({ label }) {
return <span>{label}</span> // returns a ReactElement
}Chasing one button from Component to HTMLElement
function Greeting({ name }) {
return <div>Hello, {name}</div>
}
const tree = <Greeting name="Jialin" />tree is a ReactElement. Its type field is the Greeting function itself:
{
type: Greeting,
props: { name: 'Jialin' },
key: null,
ref: null
}React walks in and sees type: Greeting. It's a function, so React calls it with the props:
// After Greeting({ name: 'Jialin' }) runs:
{
type: 'div',
props: { children: ['Hello, ', 'Jialin'] },
key: null,
ref: null
}Now type is the string 'div'. React knows this is a host component. It wraps this element in a Fiber node, which holds all the bookkeeping React needs:
{
type: 'div', // same as the element
stateNode: null, // will hold HTMLDivElement after commit
child: null, // first child Fiber
sibling: null, // next sibling
return: ParentFiber, // parent (named 'return' for historical reasons)
alternate: Fiber, // the other tree in double buffering
memoizedProps: { children: ['Hello, ', 'Jialin'] },
memoizedState: null, // for function components, this is the hooks chain
lanes: /* bitmask */, // priority for Scheduler
flags: /* bitmask */, // Placement, Update, Deletion, etc.
}A ReactElement is a throwaway snapshot of what the UI should look like. A Fiber is React's persistent work record. Elements get discarded after each render; Fibers live on (in fact two trees of them: current and workInProgress, swapped at commit).
In commit phase, React reads the Fiber's flags and calls the matching DOM operation: document.createElement('div'), insertBefore, setAttribute, and so on. That's when stateNode gets populated with the actual HTMLDivElement and the node joins the real DOM.
The full chain:
Component Greeting source code, always present
↓ called
ReactElement { type: Greeting } throwaway, discarded after this render
↓ Greeting runs, returns
ReactElement { type: 'div' } throwaway, discarded after this render
↓ React reconciles
Fiber { type: 'div', stateNode, child, ... } persistent, lives across renders
↓ commit phase
HTMLDivElement real, in the browser's DOM
The two trees, and the swap
React keeps two fiber trees: current (on screen) and workInProgress (being built). Here's the puzzle: both trees' fibers point their stateNode at the same HTMLDivElement (search createWorkInProgress, which copies stateNode from current to wip). So shouldn't rebuilding workInProgress leak into the DOM?
No, because a pointer is a read channel, not a write channel. Writing to the DOM needs explicit calls like setAttribute, appendChild, removeChild, which React only makes during commit's mutation stage (search commitMutationEffectsOnFiber). Rebuilding workInProgress only touches the fiber's own fields:
wipFiber.memoizedProps = { children: 'new text' } // Fiber's own field
wipFiber.flags |= Update // Fiber's own field
wipFiber.child = newChildFiber // Fiber's own tree pointerThink of stateNode as a TV remote, and the DOM as the TV. Both current and workInProgress hold the same remote. You can examine it, flip it around, hand it back and forth. The TV does not react. Only when someone presses a button (commit's mutation stage) does the channel change.
So why keep two fibers at all? For a node that didn't change, why not point workInProgress straight at current and save the copy? Because the fiber is scratch space React writes the next version onto. If workInProgress were the current fiber, those writes would land on the tree that's on screen. Two fibers give React a private draft to mutate, discard, and rebuild while the live tree stays untouched.
They link through alternate. current.alternate points to its twin, and back. Two addresses, and they take turns:
Render 1: current = A, workInProgress = B
commit → current moves to B
Render 2: current = B, workInProgress = A (A reset and reused)
Render 3: current = A, workInProgress = B
"The fiber for this component" is not a stable object. It ping-pongs between A and B every render. What's stable is the pair, and the stateNode both of them point at. Two cheap bookkeeping objects, recycled; one real DOM node, shared.
The mechanism is createWorkInProgress, trimmed to the parts that matter:
function createWorkInProgress(current, pendingProps) {
let workInProgress = current.alternate;
if (workInProgress === null) {
// no twin yet: allocate one, share the DOM node, wire both directions
workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
workInProgress.stateNode = current.stateNode; // same real DOM node
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// twin exists: reuse it, reset its flags
workInProgress.pendingProps = pendingProps;
workInProgress.flags = 0;
}
workInProgress.child = current.child;
return workInProgress;
}No twin yet? Allocate one, point its stateNode at the same DOM node, wire alternate both ways. Twin exists? Reuse it, zero its flags. That else branch is the recycling: two objects, reused forever, never a third.
What commit actually swaps
Two things happen at commit. Only one is visible.
mutation → change the real DOM (setAttribute, appendChild, removeChild) ← user sees this
swap → root.current = workInProgress ← internal only
layout → useLayoutEffect, componentDidMount
The DOM is mutated in place: same nodes, changed attributes, a few added or removed. By the time the swap runs, the DOM is already updated, so flipping current repaints nothing. React swaps only so the next render has the right baseline and the A/B recycling works. Mutation faces the user; the swap is React talking to itself.
The swap is one line in commitRoot:
root.current = finishedWork;
pendingEffectsStatus = PENDING_LAYOUT_PHASE;finishedWork is the workInProgress tree. The line sits after the mutation phase (DOM already updated) and right before the layout phase, which is why useLayoutEffect and componentDidMount read the new tree as current.
Mount, update, delete
The alternate story has three cases, and only one is null:
mount in wip, not in current → wip fiber's alternate = null (the real "points to null")
update in both → two fibers, alternate ↔ alternate, reused
delete in current, not in wip → no wip fiber is built for it
Deletion is the one people guess wrong. React doesn't null an alternate to mean "gone." The removed node just gets no workInProgress fiber; React pushes the old one onto its parent's deletions list and flags the parent ChildDeletion. Commit reads that list and calls removeChild. After the swap, nothing points at the orphan, so it's collected.
The code is deleteChild:
function deleteChild(returnFiber, childToDelete) {
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete]; // parent keeps a deletion list
returnFiber.flags |= ChildDeletion; // and gets flagged
} else {
deletions.push(childToDelete);
}
}returnFiber is the parent. The removed child is pushed onto the parent's deletions array and the parent gets a ChildDeletion flag. Nothing touches an alternate. Deletion is a note on the parent, not a null on the child.
This whole two-tree scheme is what makes Concurrent mode possible. React builds workInProgress, throws it away, restarts, builds again, none of it visible, because the DOM stays untouched until commit.
Where useState actually lives
The Fiber sketch had one line worth expanding:
memoizedState: null, // for function components, this is the hooks chainA function component is just a function. It runs, returns, and its locals vanish. So where does useState keep its value between renders? Not in the function. A local let resets every call.
It lives on the Fiber, in memoizedState, as a linked list of hooks. Each useState, useRef, useEffect is one node:
Fiber.memoizedState → hook1 → hook2 → hook3 → ...
useState useRef useEffect
The Fiber persists across renders (that's the whole "persistent, not throwaway" point), so state parked on it survives. Each render React calls your function; when you hit useState, it walks to the next hook node and hands back what's stored.
"The next node" is the catch. React does not know your variable names. It cannot see that one useState is name and another is age. It only counts: the first hook call gets the first node, the second call gets the second, and so on. Position is the only thing tying a value back to you.
Not a metaphor. Here is the hook node React allocates, straight from its source (search mountWorkInProgressHook):
var hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
}No name, no key, no id. The only link from one hook to the next is next. React could not match by name if it wanted to. Position is all it has.
So the order has to be identical every render. Watch what breaks when a hook hides inside an if:
function Profile({ show }) {
if (show) {
const [name] = useState('Ann') // sometimes called, sometimes skipped
}
const [age] = useState(20)
}First render, show is true. Two calls, two nodes:
call 1 → node 1 = 'Ann' (name)
call 2 → node 2 = 20 (age)
Next render, show is false. The if skips the first call. One call is left, and React still counts from the top:
call 1 (age) → node 1 = 'Ann' // age reads name's value. Wrong.
age lands on the first node and gets 'Ann'. Every hook after the skipped one shifts up a slot and reads the wrong value. React also expected two hooks and saw one, so it throws. That is the whole reason hooks stay at the top level: never inside if, for, or after an early return.
It also closes the loop with key. Change a component's key and React builds a new Fiber, with a fresh empty memoizedState. That's why a changed key resets state: no special rule, just a different Fiber.
Some confusions cleared
Why does <MyComponent /> render but <myComponent /> (lowercase) not?
JSX uses capitalization to decide: uppercase means type is a Component reference; lowercase means type is a string host name. Lowercase with an undefined reference silently becomes a DOM attempt for <mycomponent>, which browsers ignore.
Compile it and the branch is right there in the output:
<div /> → createElement("div", ...) // lowercase: a string
<MyComponent /> → createElement(MyComponent, ...) // uppercase: a variable
<myComponent /> → createElement("myComponent", ...) // lowercase, still a stringThat last line is the trap. <myComponent /> looks like a component, but the lowercase first letter makes the compiler emit the string "myComponent". React gets type: "myComponent", treats it as an unknown host tag, and the browser drops <mycomponent>. Worth noting: this split is the JSX compiler's rule, not React's. React only ever sees the type the compiler already decided.
Why can't you return a Component from another component?
What you return is supposed to be a ReactNode. A Component is a function; the function reference is not a renderable node. You need to render it: <Comp />, not just Comp.
React could check typeof === 'function' and call it for you. It refuses on purpose. A bare function in the tree is ambiguous. It might be a component, or a render prop you meant to call yourself: <Provider>{data => <div />}</Provider>. Auto-rendering every function would break that pattern. Making you write <Comp /> keeps the intent explicit.
Why is null fine to return but a function not?
null is a valid ReactNode (meaning "render nothing here"). A function reference is not. ReactNode's union type allows nullish values, primitives, elements, and arrays. It does not allow arbitrary functions.
Why does changing a list item's key reset its state?
During reconciliation React matches new elements to existing Fibers by key:
- Same key and type: reuse the Fiber, keep its
memoizedStateand its DOM node. - Different key: discard the old Fiber, build a new one with empty state.
So key is a Fiber's identity across renders. It's also why an array index makes a shaky key. Insert a row at the top and every index shifts, so React reuses the wrong Fiber for the wrong row, and state sticks to the wrong item.
The punchline
The React tree in JS memory is all ReactElements.
Each element's type is a string (becomes a DOM node) or a Component (gets called, produces more elements).
React finds the tree by recursion.
Reconciliation flattens it into Fibers.
Only after commit do the string-typed elements become real DOM.
Four names, three kinds of value, one real output.
Related: from setState to pixels and Inside React's FiberWorkLoop.