Real-time Updates with Mercure

reactolith supports Server-Sent Events (SSE) via Mercure for real-time updates from your backend. When the server publishes an update, the HTML is automatically rendered — just like with router navigation.

Mercure automatically subscribes to the current URL pathname as the topic and re-subscribes when the route changes.

Auto-Configuration (Recommended)

The easiest way to configure Mercure is to add the data-mercure-hub-url attribute to your root element:

<div id="reactolith-app" data-mercure-hub-url="https://example.com/.well-known/mercure"> <!-- Your content --> </div> <!-- With credentials (cookies): --> <div id="reactolith-app" data-mercure-hub-url="https://example.com/.well-known/mercure" data-mercure-with-credentials> <!-- Your content --> </div> import { App, Mercure } from "reactolith"; const app = new App(component); // mercureConfig is automatically set from data-mercure-hub-url attribute const mercure = new Mercure(app); mercure.subscribe(app.mercureConfig!); // optional: listen to events mercure.on("sse:connected", (url) => { console.log("Connected to Mercure hub", url); });

Manual Configuration

Alternatively, you can configure Mercure programmatically:

import { App, Mercure } from "reactolith"; const app = new App(component); const mercure = new Mercure(app); // Subscribe to Mercure hub (uses current pathname as topic by default) mercure.subscribe({ hubUrl: "https://example.com/.well-known/mercure", withCredentials: true, // Include cookies for authentication // getTopic: () => "/custom", // Optional: override topic resolution });

When the user navigates to a different route, Mercure automatically reconnects with the new pathname as the topic.

Auto-Refetch on Empty Messages

When Mercure receives an empty message (or whitespace-only), it automatically refetches the current route. This makes it easy to invalidate the current page from the backend without having to render and send the full HTML:

Backend (simple invalidation):

// Just notify that the page should refresh - no HTML needed $hub->publish(new Update('/dashboard', ''));

Instead of:

// Old way: render and send full HTML $html = $twig->render('dashboard.html.twig', $data); $hub->publish(new Update('/dashboard', $html));

This triggers a GET request to the current URL and renders the response.

Mercure Events

EventArgumentsDescription
sse:connectedurlConnection established
sse:disconnectedurlConnection closed
sse:messageevent, htmlDefault message event received
sse:namedname, event, dataNamed SSE event received (see events option)
render:successevent, htmlHTML rendered successfully
render:failedevent, htmlRender failed (no root element)
refetch:startedeventAuto-refetch triggered (empty message)
refetch:successevent, htmlAuto-refetch completed successfully
refetch:failedevent, errorAuto-refetch failed
sse:errorerrorConnection error

Named SSE Events

Mercure servers can publish messages with a custom event name (event: foo\ndata: …). Pass the names you want to receive via the events option, then listen with sse:named:

mercure.subscribe({ hubUrl: "/.well-known/mercure", events: ["sidebar", "notification"], }); mercure.on("sse:named", (name, _event, data) => { if (name === "notification") { /* … */ } });

The default message event continues to flow through sse:message and the HTML render pipeline unchanged.

Live Data with useMercureTopic

For simple live values (like notification counts, user status), use the useMercureTopic hook to subscribe to Mercure topics that send JSON data:

import { useMercureTopic } from "reactolith"; // Simple types - inferred from initial value function NotificationBadge() { const count = useMercureTopic("/notifications/count", 0); if (count === 0) return null; return <span className="badge">{count}</span>; } // Explicit type parameter function UserStatus({ userId }: { userId: number }) { const status = useMercureTopic<"online" | "offline" | "away">( `/user/${userId}/status`, "offline", ); return <span className={status}>{status}</span>; } // Complex types with interfaces interface DashboardStats { visitors: number; sales: number; conversion: number; } function Dashboard() { const stats = useMercureTopic<DashboardStats>("/dashboard/stats", { visitors: 0, sales: 0, conversion: 0, }); return ( <div> <span>Visitors: {stats.visitors}</span> <span>Sales: {stats.sales}</span> <span>Conversion: {stats.conversion}%</span> </div> ); }

Backend:

// Push JSON data to topic $hub->publish(new Update('/notifications/count', json_encode(42)));
Note: When using useMercureTopic, make sure app.mercureConfig is set. Either add data-mercure-hub-url to your root element (recommended), or set app.mercureConfig manually before any component subscribes.

Custom Live Regions (Partial Updates)

For partial updates (e.g., updating a sidebar across all pages), use the built-in MercureLive component:

Setup:

import { App, Mercure, MercureLive } from "reactolith"; import loadable from "@loadable/component"; const component = loadable( async ({ is }: { is: string }) => { // Don't lazy-load MercureLive – it must be available synchronously. if (is === "mercure-live") return MercureLive; return import(`./components/${is}.tsx`); }, { cacheKey: ({ is }) => is, resolveComponent: (mod, { is }) => { if (is === "mercure-live") return mod; return mod.default || mod[is]; }, }, ); const app = new App(component); const mercure = new Mercure(app); app.mercureConfig = { hubUrl: "/.well-known/mercure", withCredentials: true, }; mercure.subscribe(app.mercureConfig);

Example: Live Sidebar

export function Sidebar({ children }: { children: React.ReactNode }) { return <aside className="sidebar">{children}</aside>; }

HTML Usage:

<div id="reactolith-app"> <nav>...</nav> <!-- This region updates live --> <mercure-live topic="/sidebar"> <sidebar> <ul> <li>Initial menu item 1</li> <li>Initial menu item 2</li> </ul> </sidebar> </mercure-live> <main>...</main> </div>

Backend:

// Re-render the sidebar partial $html = $twig->render('_sidebar.html.twig', [ 'menuItems' => $updatedMenuItems, ]); // Push to all clients $hub->publish(new Update('/sidebar', $html));

Template (_sidebar.html.twig):

<sidebar> <ul> {% for item in menuItems %} <li>{{ item.label }}</li> {% endfor %} </ul> </sidebar>