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
| Event | Arguments | Description |
sse:connected | url | Connection established |
sse:disconnected | url | Connection closed |
sse:message | event, html | Default message event received |
sse:named | name, event, data | Named SSE event received (see events option) |
render:success | event, html | HTML rendered successfully |
render:failed | event, html | Render failed (no root element) |
refetch:started | event | Auto-refetch triggered (empty message) |
refetch:success | event, html | Auto-refetch completed successfully |
refetch:failed | event, error | Auto-refetch failed |
sse:error | error | Connection 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>