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.