React – Rendering to the DOM

June 12, 20264 min readUpdated 8/18/2026

A React application replaces the contents of exactly one DOM element and builds everything else itself. This post is about that handover — the one line of HTML, the entry file that takes it over, and what belongs there.

The one empty div

Here is the entire HTML file of a real, fairly large React app:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PizzaHub — order pizza online</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

The navbar, the menu, the cart drawer, the Stripe checkout, the admin dashboard — none of it is in that file. React creates all of it at runtime and puts it inside <div id="root"></div>.

The id is not special. root is just the convention; app would work identically as long as the entry file looks for the same name.

createRoot

The entry file is where React attaches itself. Two lines do the work:

import { createRoot } from 'react-dom/client';
import App from './App.tsx';

createRoot(document.getElementById('root')!).render(<App />);

createRoot takes the DOM node and returns a root — a handle to the piece of the page React now owns. render hands it the top component. From then on you never touch the DOM directly; you change state, and React works out the difference.

The ! is TypeScript, not React. getElementById is typed as possibly returning null, and ! asserts that it does not. It is honest here — if that div is missing the app cannot start and a crash on line one is the correct outcome.

If you are following an older tutorial

You may see this instead:

// React 17 and earlier. Removed in React 19 — this now throws.
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

ReactDOM.render was deprecated in React 18 and deleted in 19. If you hit "ReactDOM.render is not a function", that is what happened. The replacement is createRoot from react-dom/client — note the /client, which is a different entry point from the package root.

StrictMode

Almost every real entry file wraps the app in <StrictMode>:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

It renders nothing. What it does is deliberately run your components twice in development — every render, every effect, every state updater — and it does this to catch bugs that would otherwise only appear in production.

This confuses everyone once. You add a console.log to a component and it prints twice. You write an effect that fetches data and see two network requests. Neither is a bug in React; both are React telling you something:

  • Two renders — if rendering twice produces a different result, your component is not pure. It is reading or writing something outside itself during render, and that will break in ways that are miserable to debug.
  • Two effect runs — the effect is mounted, cleaned up, and mounted again. If the second run misbehaves, your cleanup function is missing or incomplete.

The menu fetch in this app survives it, because it aborts properly on cleanup:

useEffect(() => {
  const controller = new AbortController();

  async function load() {
    /* … */
    const [productData, toppingData, crustData] = await Promise.all([
      api.get<Product[]>('/api/products', { signal: controller.signal }),
      api.get<Topping[]>('/api/toppings', { signal: controller.signal }),
      api.get<Crust[]>('/api/crusts', { signal: controller.signal }),
    ]);
    /* … */
  }

  void load();
  return () => controller.abort();     // ← what makes StrictMode's double run harmless
}, [reloadToken]);

None of this happens in the production build. The temptation when you first meet the double render is to delete StrictMode. Resist it — you are not fixing the problem, you are turning off the thing that found it. The Component Lifecycle with useEffect goes through the cleanup rules properly.

What else belongs in the entry file

Global stylesheets and context providers, and nothing much else. Here is the real one:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';

// Bootstrap's stylesheet first, then our theme, so our overrides win on equal specificity.
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/theme.scss';

import App from './App.tsx';
import { CartProvider } from './context/CartContext';
import { AuthProvider } from './context/AuthContext';
import { ToastProvider } from './context/ToastContext';
import { MenuProvider } from './context/MenuContext';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <AuthProvider>
        <MenuProvider>
          <CartProvider>
            <ToastProvider>
              <App />
            </ToastProvider>
          </CartProvider>
        </MenuProvider>
      </AuthProvider>
    </BrowserRouter>
  </StrictMode>,
);

Two things to take from that.

Importing CSS from a JavaScript file is normal here. Vite understands it and injects the stylesheet. The order of those two imports is load-bearing: Bootstrap first, our overrides second, so that on equal specificity ours win.

The provider nesting is a real tree, and order matters when one provider consumes another. CartProvider calls useMenu() — it needs the catalogue to re-price a saved cart — so it must sit inside MenuProvider. Get that backwards and you get "useMenu must be used inside a <MenuProvider>" at startup. Where there is no such dependency, nest for readability: longest-lived outermost.

Passing Data Deeply with Context covers what those providers are doing.

Next

Your First Component — what <App /> actually is.