# Reactolith — complete documentation corpus > This file aggregates every documentation page on https://reactolith.github.io/ into a single Markdown stream for use by LLMs, RAG pipelines, and AI search engines. The canonical home of each section is linked at the top of that section. The short index lives at . Project: **reactolith** Tagline: HTML with React components — backend-driven React, like Hotwire Turbo but for a React tree. License: MIT Author: Franz Mayr-Wilding Repository: https://github.com/reactolith/reactolith npm: https://www.npmjs.com/package/reactolith Peer deps: React 19, react-dom 19. Node 18+. Keywords: react, html, hydration, backend-driven, server-rendered, morph, hotwire, turbo, inertia alternative, mercure, sse, symfony, rails, laravel, django, twig, erb, blade, jinja, web-types, ssr, majestic monolith. --- ## Overview (https://reactolith.github.io/) Reactolith hydrates server-rendered HTML into React. Any HTML tag with a hyphen (e.g. ``) is resolved to a React component; everything else stays plain DOM. The same render pipeline handles initial load, link navigation, form submits, and Mercure server-sent events. It is **Hotwire Turbo for React**: the server returns HTML, reactolith diffs that HTML against the live React tree, and applies only the differences using React's reconciler. Component state, focus, scroll position, open dialogs and mounted iframes all survive across navigation. Compared to Inertia.js, reactolith does **not** ship JSON page props and does **not** replace the page-level component on every visit. The wire format is HTML; the controller and template are the contract. That makes the [Majestic Monolith](https://signalvnoise.com/svn3/the-majestic-monolith/) practical for teams that want React on a Rails / Symfony / Laravel / Django app. Highlights: - Backend-agnostic — works with any backend that emits HTML, or no backend at all. - Morphing navigation — like Turbo's `morph`, the tree is diffed in place. - Live forms — server can add/remove fields without losing input state or focus. - Realtime via Mercure SSE — pushed HTML flows through the same render path. - Scroll restoration — browser-like back/forward. - IDE autocomplete — generate JetBrains/VS Code web-types from your components. - No build step required at the consumer site. Two-file tour: ```html

Hello, {{ user.name }}

Increment
``` ```tsx // app.tsx import { App } from "reactolith"; new App(({ is }) => { switch (is) { case "app-counter": return Counter; case "ui-button": return Button; case "app-autosave-input": return AutosaveInput; } }); ``` Any tag with a hyphen → React component. Everything else → native DOM. --- ## Installation (https://reactolith.github.io/installation/) `reactolith` is published on npm. Install it alongside React and React DOM. ```bash npm install reactolith npm install react react-dom ``` `react` and `react-dom` are **peer dependencies** — install them in your project too. Requirements: - Node 18 or newer. - React 19. - A bundler that supports `import.meta.glob` (Vite, Rspack, …) is recommended for the loader workflow. Verify the install: ```ts import { App } from "reactolith"; console.log(typeof App); // "function" ``` --- ## Quick Start (https://reactolith.github.io/quick-start/) The minimum working app: ```html

Hello world

Click me
``` ```tsx // src/main.tsx import { App } from "reactolith"; import { MyButton } from "./components/my-button"; function resolveComponent({ is }: { is: string }) { if (is === "my-button") return MyButton; throw new Error(`Unknown component: ${is}`); } new App(resolveComponent); ``` Any tag name with a hyphen is treated as a custom React component and resolved through the function you pass to `new App(...)`. Everything else (`

`, `
`, ``, …) is rendered as a native HTML element. ### Lazy-loading a folder of components For larger apps — including drop-in shadcn directories — `createLoader` resolves tag names to a folder of files without any per-component setup: ```tsx import { App, createLoader } from "reactolith"; const component = createLoader({ modules: import.meta.glob("./components/ui/*.tsx"), prefix: "ui-", }); new App(component); ``` `` → `./components/ui/button.tsx` (named export `Button` or `default`). Multi-segment names fall back to a parent file — `` finds `accordion.tsx` and picks its `AccordionItem` export. Layer custom components on top of a base set by passing multiple module maps; earlier maps win: ```ts const component = createLoader({ modules: [ import.meta.glob("./components/custom/*.tsx"), // wins on conflict import.meta.glob("./components/ui/*.tsx"), ], prefix: "ui-", }); ``` `createLoader` options: - `modules` — `ModuleMap` or `ModuleMap[]` (output of `import.meta.glob`). Earlier maps take priority. - `prefix` — string prefix stripped from `is` before resolving (e.g. `"ui-"`). - `fallback` — `ReactNode` rendered while a component is lazy-loading. Default `null`. - `onMissing` — `(name, is) => ComponentType | null` called when no module resolves. ### Custom root component & selector ```tsx import { App, createLoader } from "reactolith"; import { AppProvider } from "./providers/app-provider"; const component = createLoader({ modules: import.meta.glob("./components/**/*.tsx"), }); new App(component, AppProvider, "#app"); ``` --- ## How It Works (https://reactolith.github.io/how-it-works/) 1. **Initial load** — Your backend renders HTML with Twig/ERB/Blade/etc.; reactolith hydrates it into React components. 2. **Navigation** — Clicking links fetches new HTML via AJAX; React reconciles the differences in place. 3. **Real-time** — Mercure pushes HTML updates from the server; the UI updates automatically. 4. **State preserved** — React component state survives both navigation and real-time updates. ### Avoiding FOUC Give your root element the `hidden` class (and define `.hidden { display: none }` in your CSS). After the first React commit, reactolith removes that class to reveal the app. ```html ``` Configurable via `AppOptions`: ```ts new App(component, AppProvider, "#reactolith-app", undefined, document, fetch, { hideUntilHydrated: true, hiddenClass: "invisible", }); ``` Custom `appProvider` implementations should call `app.notifyHydrated()` from a `useEffect`. ### Navigation without losing state Reactolith fetches the **next HTML page** and applies **only the differences** using React's reconciler. Component state — toggles, inputs, focus — is preserved. ### Links inside custom components The Router only intercepts clicks on actual `` elements with an `href`. Three ways to make custom components navigate: 1. Wrap with a real anchor (``). 2. Use the `asChild` render-as-child pattern (Radix/Base UI/shadcn convention). 3. Programmatic navigation via `useRouter()`: ```tsx import { useRouter } from "reactolith"; function SaveAndGo() { const { router } = useRouter(); return ; } ``` Same-origin anchors are intercepted; external links, hash links, `target="_blank"`, `rel="external"`, and `download` opt out of SPA navigation. --- ## Props (https://reactolith.github.io/props/) Attributes on reactolith components are mapped to React props by these rules: | HTML attribute | React prop / value | Notes | |-----------------------------|-----------------------------------|-------| | `name="test"` | `name: "test"` | Strings pass through | | `enabled` (no value) | `enabled: true` | Empty / boolean attrs become `true` | | `data-foo="baa"` | `dataFoo: "baa"` | Custom component: camelCased | | `data-foo="baa"` | `data-foo: "baa"` | Native element: kept as `data-*` | | `aria-label="x"` | `aria-label: "x"` | Native element: passed through verbatim | | `class="x"` | `className: "x"` | Auto-renamed | | `for="email"` | `htmlFor: "email"` | Common lowercase HTML attributes mapped to React camelCase | | `json-config='{"x":1}'` | `config: { x: 1 }` | Attributes prefixed `json-` are JSON-parsed | | `as="{my-other}"` | `as: ` | `{...}` resolves to a React component | | `key="abc"` | (used as React key) | Reserved | | `#anything="…"` | (ignored) | Attributes starting with `#` are dropped | Example: ```html ``` ```tsx const props = { enabled: true, name: "test", dataFoo: "baa", as: , config: { foo: "baa" }, }; ``` ### Encoding props server-side | Prop value (server) | HTML attribute (rendered) | Notes | |---------------------------------|----------------------------------|-------| | `"text"` | `name="text"` | Strings | | `42` | `name="42"` | Numbers stringified | | `true` | `name` | Bare attribute (not `name="true"`) | | `false` / `null` / `undefined` | *(omit)* | Don't render the attribute | | `{ x: 1 }` | `json-name='{"x":1}'` | `json-` prefix + JSON-encoded | | `[1, 2, 3]` | `json-name='[1,2,3]'` | Same rule for arrays | Reference helper: ```ts function renderAttrs(props: Record): string { const out: string[] = []; for (const [name, value] of Object.entries(props)) { if (value === false || value == null) continue; if (value === true) { out.push(name); continue; } if (typeof value === "object") { out.push(`json-${name}='${JSON.stringify(value).replace(/'/g, "'")}'`); continue; } out.push(`${name}="${String(value).replace(/"/g, """)}"`); } return out.join(" "); } ``` Invalid JSON in a `json-*` attribute logs a warning and passes `undefined`; the App emits a `json-parse:failed` event for telemetry. --- ## Slots (https://reactolith.github.io/slots/) Any direct child of a custom component carrying a `slot` attribute is captured as a slot prop, with the slot's **inner content** (not the wrapping element). ```html
My footer content
``` ```tsx function MyComponent({ header, footer, children }) { return (
{header}
{children}
{footer}
); } ``` Use `