Skip to content
AtomicReps
How React Works

JSX does not call your component

Lesson 1 of 28

JSX does not call your component

A lesson from How React Works. Play it above, or read it through below.

Level I · The Rendering Model
  • 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: Building an element runs no component code and creates no DOM node.

    The object is also cheap enough that optimizing its creation is wasted work: memoizing a tag to save the allocation gains nothing and adds a dependency array. What a memoized element does provide is a stable object identity, which a later lesson covers.

    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. In a common failure, the patch works in the production build on staging, throws in the development build in CI, and gets filed against whoever last changed 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 />?

    When Article is written as a direct call, its hooks are recorded

    Article() and <Article /> are not the same operation. The call runs Article's body inline, so its hooks are recorded in the caller's hook list, and the caller's hook count now changes whenever the caller runs Article() conditionally or in a loop. That is how a component that never wrote a conditional hook still breaks the Rules of Hooks. The JSX form passes React a type and a props object instead, and lets React make the call. Letting React make the call gives you:

    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

    A direct call is where "components are just functions" stops being true, and the error it causes names the caller, not Article, even when the last edit was a hook added to Article. The caller is still the file to fix. Put {Article()} behind a ternary and the render that first includes it throws "Rendered more hooks than during the previous render" against the caller. 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 behave in opposite ways. key never reaches props: React stores it on the element itself, coerces it to a string, and in development reading props.key hits a getter that warns and returns undefined, which is the undefined the Row list just printed.

    ref does the opposite in React 19: it is 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 is collected into a rest object and goes wherever you spread that rest, so a caller who hands a ref to a component that spreads its props onto a layout wrapper

    The bug report then says "focus does nothing", filed 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 do not add forwardRef to 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 already states which one it supports: the upgrade guide titles a Note "New JSX Transform is now required." The two targets do not always build the same props. On React 19.3.0, createElement still copies a function component's defaultProps into props, and react/jsx-runtime ignores them. Both targets pass ref through as an ordinary prop.

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

    What react.dev does not document is the shape of the call your build emits, so do not base any reasoning on it. Rely on the part that is documented and has not changed: the case rule.

    The compiler decides from the source text alone what goes in the type slot: a tag that is one lowercase name becomes a quoted string, and a capitalized name, or any dotted name such as <motion.div>, becomes a reference to whatever that expression holds at runtime. It does not look up what the name refers to, and React never makes a decision based on the name. Neither of the two failures below gets reported as a JSX bug; both get reported 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: The case of the tag decides the type slot, not what the name refers to.

  • 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.

    Because position is the whole address, wrapping a subtree on only some renders is never only a style change: 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, a different position, so React destroys the node and mounts a fresh one. Someone files the bug "the note editor clears itself when I pin a row" against a PR that only added a wrapper, and React prints no warning.

  • 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 create 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 nested declaration causes the bug, and git blame shows it unchanged.

    Anything that creates a function during render creates a new type during render, so a factory or higher-order component called in the render body causes the same bug, while the same call at module scope runs once and is fine. React makes the call and compares the type you passed, and that type is a different function every time. Move the inner component to module scope and pass what the inner component needed as props.