React Without A Build Step

Before JSX and bundlers existed, React was just a function call -- writing it that way once makes everything that comes after make sense.

May 15, 20265 min read1 / 2

The first time I wrote HTML-looking syntax inside a JavaScript function, something felt off. That feeling lasted about ten minutes. Then I understood what it was doing and it never came back.

But before JSX -- before the compiler that turns <div> into a function call -- there is a simpler entry point: React.createElement. No build step. No configuration. Just an HTML file and two script tags.

That is where this series starts.

The HTML Shell

Create an index.html. The only unusual thing about it is one div:

HTML
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Padre Gino's</title> </head> <body> <div id="root">not rendered</div> <script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script> <script src="./src/App.js"></script> </body> </html>

The text "not rendered" inside that div is a sentinel. If you open the page and see those words, something went wrong before React had a chance to run. If React mounts successfully, it overwrites the div's contents entirely. It costs nothing and immediately tells you whether the page loaded correctly.

Why Two Script Tags

React ships as two separate packages, and the split is intentional.

react is the universal interface. It contains the component model and the hooks API. The same react package is used by React Native, React Three Fiber, and every other React renderer that exists. It has no concept of a browser.

react-dom is the browser rendering layer. It knows how to walk a React element tree and convert it into actual DOM nodes. If you were building a mobile app, you would use react-native instead of react-dom, but you would still depend on the same react package.

How React.createElement flows from a description to real DOM nodes ExpandHow React.createElement flows from a description to real DOM nodes

That separation is why the mental model transfers across platforms. The way you think about components and state works the same in a browser, on a phone, or in a WebGL scene. The renderer changes. The model does not.

Writing the First Component

Create src/App.js. Inside it, write this:

JavaScript
const App = () => { return React.createElement( "div", {}, React.createElement("h1", {}, "Padre Gino's") ); }; const container = document.getElementById("root"); const root = ReactDOM.createRoot(container); root.render(React.createElement(App));

React.createElement takes three arguments: the element type, an object of props, and the children. It returns a plain JavaScript object -- a description of what the UI should look like. It does not touch the DOM. It does not render anything. It just returns data.

The rendering happens on the last line. ReactDOM.createRoot attaches React to the #root div. Calling .render() takes the element description React produces and walks it to build the actual DOM.

Before running this in the browser, stop and ask yourself what you expect to see. Not as a habit that sounds nice in theory -- as a practice that makes debugging faster. If your expectation matches what the browser shows, you understood the code. If it does not, you have found the exact boundary where your mental model breaks.

In this case: a div containing an h1 saying "Padre Gino's" should appear inside #root. No more "not rendered".

Components Are Functions, Elements Are Descriptions

There is a distinction worth making explicit before it causes confusion later.

A component is a function. App is a component. Calling it returns an element.

An element is the plain object that React.createElement returns. It describes what should be in the DOM. It is not a DOM node.

When you write React.createElement(App) -- passing a function instead of a string like "div" -- React calls that function and gets back the element tree the function returns. If you pass a string, React creates a native DOM element of that type. If you pass a function, React invokes the function to find out what to render.

That is why "div" (lowercase string) gives you an actual div, while App (a reference to your function) gives you whatever App returns. One is a native element type; the other is a component.

What This Shows

Most React tutorials skip this step entirely. They generate a Vite project, show you a folder full of config files, and treat the toolchain as a given. The trade-off is that when something breaks in the build, there is nothing in your mental model to reason from.

The raw React.createElement approach shows you that React, at its core, is just a function that returns data. The virtual DOM is just an object tree. The rendering step is a separate concern handled by a separate package.

In the next post, we make the Pizza component that drives Padre Gino's menu -- and the reason props exist becomes obvious the moment you try to render five pizzas with five different names.

The Essentials

  1. Two-package split. react is the universal model (works in Native, WebGL, anywhere). react-dom is the browser-specific renderer. You always need both when building for the web.
  2. React.createElement returns data, not DOM. It produces a plain JavaScript object describing the UI. The DOM is only touched by ReactDOM.createRoot().render().
  3. Components are functions; elements are descriptions. Pass a string to createElement for a native element. Pass a function for a component -- React will call that function.
  4. Predict before you run. Before saving and switching to the browser, ask what you expect to appear. A mismatch between expectation and reality is a learning opportunity.
  5. The sentinel pattern. Put meaningful fallback text in #root so you immediately know if React failed to mount.

Further Reading and Watching