Skip to content
AtomicReps
React Under the Hood

JSX is a function call

Lesson 1 of 28

JSX is a function call

A lesson from React Under the Hood. Play it above, or read it through below.

  • An element is a description. Nothing has run.

    The common read: A JSX tag is a call, so <Greeting name="Taylor" /> has already run Greeting's body by the time that line finishes, and what I am holding is its output.

    Writing the tag runs none of your component's code. It builds one plain object, a description of what you want React to do later: no DOM node, no rendered output, and not a single line of Greeting executed.

    Takeaway: A work order is not a building, and nobody has broken ground.

    The object is also cheap enough that optimizing its creation is wasted work: memoizing a tag to save the allocation buys nothing and costs a dependency array. The one thing a memoized element does buy is a stable object identity, which is a later lesson's material.

    Patch a prop after the element is built and a production build

    React freezes the element and its props shallowly in development, so an assignment like el.props.name = "Morgan" throws there. That arrives as "works on staging, red in CI", filed against whoever last touched the test runner. Treat every element you did not create as opaque: read it if you must, render it, never write to it.

  • React decides when your component function runs.

    The common read: Article() and <Article /> are the same operation with the syntax sugar removed, so calling a component directly is a style preference.

    If a component is a function and the tag is sugar, what exactly changes when you write Article() instead of <Article />?

    Written as a direct call, Article's hooks land

    Article() and <Article /> are not the same operation. The call runs Article's body inline, so its hooks land in the caller's hook list, and the caller's hook count now varies with Article's branching. That is how a component that never wrote a conditional hook still breaks the Rules of Hooks. The JSX form hands React a type and a props object instead, and lets React make the call. That purchase buys:

    local state tied to the component's identity in the tree a type that participates in reconciliation, so React knows not to reuse a Feed as a Profile room for the browser to work between component calls, so a large tree does not block the main thread the ability to skip components that do not need re-rendering

    This is the seam where "components are just functions" stops being true, and where blame goes wrong. Put {Article()} behind a ternary and the flip that first includes it throws "Rendered more hooks than during the previous render" against the caller, a file nobody has touched in a month. React can only name the hook list that changed, and the hook list that changed belongs to whoever made the call.

  • key is not a prop. ref is.

    The common read: key is a prop my component receives, and ref is the special one that still needs forwardRef.

    The two reserved names travel in opposite directions, and neither does what its spelling suggests. key never reaches props: React files it on the element itself, coerces it to a string, and in development reading props.key hits a getter that warns and hands back undefined, which is the undefined the probe just printed.

    ref runs the other way in React 19: it arrives as an ordinary entry in props. forwardRef is no longer required for new function components, and element.ref is now a deprecated accessor that warns when you touch it.

    Because ref is an ordinary prop now, it rides a rest object wherever you spread that rest, so a caller who hands a ref to a component that spreads its props onto a layout wrapper

    That is the expensive inversion: the bug is filed as "focus does nothing" against the component that never asked for a ref at all. Read the element for type, props and key. Pass ref deliberately to the tag you mean, and stop reaching for forwardRef on new code.

  • The type slot is decided by case. The module is decided by your build.

    The common read: JSX has one fixed compile target, React.createElement, so what my build emits is a React fact I can reason about behaviour from.

    Your tags compile into a call, but a call into which module, and who decided that: React, or your build?

    The compile target is a build setting, not a React fact, and React 19 has already ruled on which one you may hold. The upgrade guide titles a Note "New JSX Transform is now required," and ties the requirement to this lesson's own material: it names ref as a prop, the same rule two screens back, as one of the improvements the modern transform requires.

    "jsx": "react-jsx" compiles your tags against react/jsx-runtime and is the supported target. "jsx": "react"

    What react.dev never explains anywhere is the shape of the call your build emits, so build no model on it. Build on the half that is documented and has not moved: the case rule.

    The compiler decides from the source text alone what goes in the type slot: a lowercase tag becomes a quoted string, a capitalized identifier becomes a reference to whatever that variable holds at runtime. No lookup of what the name means, and no naming question ever reaches React. Neither of the two failures below files as a JSX bug; both get filed against the design system instead.

    A capitalized binding holding a config string renders an unknown tag, with your import sitting unused two lines up. A lowercase destructured binding renders a literal <tag>, while the component someone passed in is never consulted.

    Takeaway: Read the case, not the call.

  • Same type survives. Different type is destroyed.

    The common read: A node keeps its state for as long as the same component is still on screen, whatever the tree around it does.

    React does not hold your state under the component's name. It holds it under a POSITION, which is the parent plus the index among siblings, or the parent plus the key when a key is present, because a key substitutes into the position in place of sibling order.

    For a component type, React compares

    At every position React checks the type before it looks at anything else. Same type and the node survives with its state, its effects and its DOM node intact while props update. Different type and React tears that node down along with everything underneath it, then mounts a fresh subtree. For a string type that check is string equality.

    Position being the whole address is why wrapping is never cosmetic: hoist a subtree into a variable, render it bare in one branch and inside a highlight div in the other, and the same element object lands under a different parent, which is a different position, so React destroys the node and mounts a fresh one. That arrives as "the note editor clears itself when I pin a row," filed against a PR that only added a wrapper. React says nothing.

  • A component declared inside a component is a new type every render.

    The common read: Declaring a component inside another component is a style problem, so the worst it costs me is a function allocation per render.

    What does the component you declared inside another component actually cost, per parent render?

    The nested declaration costs you

    Declare a component inside another component and you mint a new function object on every parent render, so the type at that position fails reference identity and React destroys the subtree, every time. The symptom never points at the cause: an input clears itself, a form resets, focus is lost, a fetch refires on every keystroke, and the ticket goes to whoever owns the fetch layer, because the declaration that caused it has not been edited in months.

    Takeaway: The declaration walks; its git blame is spotless.

    Anything that mints a function during render mints a type during render, so a factory or higher-order component called in the render body is the same bug in different clothes, while the same call at module scope is minted once and is fine. The call is React's, so React compares what you handed it, and what you handed it is different every time. Hoist to module scope and pass what the inner component needed as props.