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

To hide the unhydrated tree until React mounts, give your root element the class hidden (and define .hidden { display: none } in your CSS). After the first React commit, reactolith removes that class to reveal the app:

<div id="reactolith-app" class="hidden"> <h1>Hello world</h1> </div>

This is configurable via AppOptions:

new App(component, AppProvider, "#reactolith-app", undefined, document, fetch, { hideUntilHydrated: true, // default: true; pass false to opt out hiddenClass: "invisible", // default: "hidden" });

Custom appProvider implementations should call app.notifyHydrated() from a useEffect so the hidden class is removed and app.onHydrated(...) listeners fire.

Navigation Without Losing State

When navigating, reactolith fetches the next HTML page and applies only the differences using React's reconciler. Component state — toggles, inputs, focus — is preserved.

<!-- page1.html --> <div id="reactolith-app"> <h1>Page 1</h1> <ui-toggle json-pressed="false">Toggle</ui-toggle> <a href="page2.html">Go to page 2</a> </div> <!-- page2.html --> <div id="reactolith-app"> <h1>Page 2</h1> <ui-toggle json-pressed="true">Toggle</ui-toggle> <a href="page1.html">Go to page 1</a> </div>

Only the <h1> text and the pressed prop are updated — everything else remains untouched.

Links inside custom components

The Router only intercepts clicks on actual <a> elements with an href attribute. A custom component like <ui-button> is not an anchor, so passing href to it does nothing unless the component itself renders an <a>. There are three ways to make something clickable navigate:

  1. Use a real anchor. The simplest option — wrap your component, or have your component wrap a child, with <a>:

    <a href="/settings"> <ui-button variant="ghost">Settings</ui-button> </a>

    Style the anchor to remove its default underline if needed. Semantically this is the correct shape: a link styled like a button.

  2. Render-as-child (asChild) pattern. Many React component libraries built on Radix / Base UI (shadcn/ui being the common example) expose an asChild prop that swaps the rendered element for its child while keeping the styles and behavior. That gives you a single element that is the anchor:

    <ui-button as-child> <a href="/settings">Settings</a> </ui-button>

    Whether this works depends on the component library, not on reactolith. Reactolith only cares that there's eventually an <a href> in the DOM — the Router picks it up either way.

  3. Programmatic navigation. When there's no link to click — e.g. after some local logic completes — reach into the Router from a hook:

    import { useRouter } from "reactolith"; function SaveAndGo() { const { router } = useRouter(); return ( <button onClick={() => router.visit("/done")}> Save </button> ); }

    Form submits go through the Router automatically — you only need router.visit() for navigation that isn't tied to a click on a link or a form submit.

Same-origin anchors are intercepted; external links, hash links, target="_blank", rel="external", and download attributes all opt out of SPA navigation and let the browser handle them normally.