@benqoder/beam
v0.5.3
Published
A lightweight, declarative UI framework for building interactive web applications with WebSocket RPC. Beam provides server-driven UI updates with minimal JavaScript configuration—just add attributes to your HTML.
Readme
Beam
A lightweight, declarative UI framework for building interactive web applications with WebSocket RPC. Beam provides server-driven UI updates with minimal JavaScript configuration—just add attributes to your HTML.
Features
- WebSocket RPC - Real-time communication without HTTP overhead
- Declarative - No JavaScript needed, just HTML attributes
- Auto-discovery - Handlers are automatically found via Vite plugin
- Modals & Drawers - Built-in overlay components
- Smart Loading - Per-action loading indicators with parameter matching
- DOM Updates - Server-driven UI updates
- Real-time Validation - Validate forms as users type
- Input Watchers - Trigger actions on input/change events with debounce/throttle
- Conditional Triggers - Only trigger when conditions are met (
beam-watch-if) - Dirty Form Tracking - Track unsaved changes with indicators and warnings
- Conditional Fields - Enable/disable/show/hide fields based on other values
- Deferred Loading - Load content when scrolled into view
- Polling - Auto-refresh content at intervals
- Hungry Elements - Auto-update elements across actions
- Confirmation Dialogs - Confirm before destructive actions
- Instant Click - Trigger on mousedown for snappier UX
- Offline Detection - Show/hide content based on connection
- Navigation Feedback - Auto-highlight current page links
- Conditional Show/Hide - Toggle visibility based on form values
- Auto-submit Forms - Submit on field change
- Boost Links - Upgrade regular links to AJAX
- History Management - Push/replace browser history
- Placeholders - Show loading content in target
- Keep Elements - Preserve elements during updates
- Toggle - Client-side show/hide with transitions (no server)
- Dropdowns - Click-outside closing, Escape key support (no server)
- Collapse - Expand/collapse with text swap (no server)
- Class Toggle - Toggle CSS classes on elements (no server)
- Reactive State - Fine-grained reactivity for UI components (tabs, accordions, carousels)
- Multi-Render - Update multiple targets in a single action response
- Async Components - Full support for HonoX async components in
ctx.render()
Installation
npm install @benqoder/beamQuick Start
1. Add the Vite Plugin
// vite.config.ts
import { beamPlugin } from "@benqoder/beam/vite";
export default defineConfig({
plugins: [
beamPlugin({
actions: "./actions/*.tsx",
}),
],
});2. Initialize Beam Server
// app/server.ts
import { createApp } from "honox/server";
import { beam } from "virtual:beam";
const app = createApp({
init: beam.init,
});
export default app;3. Add the Client Script
// app/client.ts
import "@benqoder/beam/client";4. Create an Action
// app/actions/counter.tsx
export function increment(c) {
const count = parseInt(c.req.query("count") || "0");
return <div>Count: {count + 1}</div>;
}5. Use in HTML
<div id="counter">Count: 0</div>
<button beam-action="increment" beam-target="#counter">Increment</button>Core Concepts
Actions
Actions are server functions that return HTML. They're the primary way to handle user interactions.
// app/actions/demo.tsx
export function greet(c) {
const name = c.req.query("name") || "World";
return <div>Hello, {name}!</div>;
}<button beam-action="greet" beam-data-name="Alice" beam-target="#greeting">
Say Hello
</button>
<div id="greeting"></div>Including Input Values
Use beam-include to collect values from input elements and include them in action params. Elements are found by beam-id, id, or name (in that priority order):
<!-- Define inputs with beam-id, id, or name -->
<input beam-id="name" type="text" value="Ben" />
<input id="email" type="email" value="[email protected]" />
<input name="age" type="number" value="30" />
<input beam-id="subscribe" type="checkbox" checked />
<!-- Button includes specific inputs -->
<button
beam-action="saveUser"
beam-include="name,email,age,subscribe"
beam-data-source="form"
beam-target="#result"
>
Save
</button>
<div id="result"></div>The action receives merged params with proper type conversion:
{
"source": "form",
"name": "Ben",
"email": "[email protected]",
"age": 30,
"subscribe": true
}Type conversion:
checkbox→boolean(checked state)number/range→number- All others →
string
Modals
Two ways to open modals:
1. beam-modal attribute - Explicitly opens the action result in a modal, with optional placeholder:
<!-- Shows placeholder while loading, then replaces with action result -->
<button
beam-modal="confirmDelete"
beam-data-id="123"
beam-size="small"
beam-placeholder="<div>Loading...</div>"
>
Delete Item
</button>2. beam-action with ctx.modal() - Action decides to return a modal:
// app/actions/confirm.tsx
export function confirmDelete(
ctx: BeamContext<Env>,
{ id }: Record<string, unknown>,
) {
return ctx.modal(
<div>
<h2>Confirm Delete</h2>
<p>Are you sure you want to delete item {id}?</p>
<button beam-action="deleteItem" beam-data-id={id} beam-close>
Delete
</button>
<button beam-close>Cancel</button>
</div>,
{ size: "small" },
);
}ctx.modal() accepts JSX directly - no wrapper function needed. Options: size ('small' | 'medium' | 'large'), spacing (padding in pixels).
<button beam-action="confirmDelete" beam-data-id="123">Delete Item</button>Drawers
Two ways to open drawers:
1. beam-drawer attribute - Explicitly opens in a drawer:
<button
beam-drawer="openCart"
beam-position="right"
beam-size="medium"
beam-placeholder="<div>Loading cart...</div>"
>
Open Cart
</button>2. beam-action with ctx.drawer() - Action returns a drawer:
// app/actions/cart.tsx
export function openCart(ctx: BeamContext<Env>) {
return ctx.drawer(
<div>
<h2>Shopping Cart</h2>
<div class="cart-items">{/* Cart contents */}</div>
<button beam-close>Close</button>
</div>,
{ position: "right", size: "medium" },
);
}ctx.drawer() accepts JSX directly. Options: position ('left' | 'right'), size ('small' | 'medium' | 'large'), spacing (padding in pixels).
<button beam-action="openCart">Open Cart</button>Multi-Render Array API
Update multiple targets in a single action response using ctx.render() with arrays:
1. Explicit targets (comma-separated)
export function refreshDashboard(ctx: BeamContext<Env>) {
return ctx.render(
[
<div class="stat-card">Visits: {visits}</div>,
<div class="stat-card">Users: {users}</div>,
<div class="stat-card">Revenue: ${revenue}</div>,
],
{ target: "#stats, #users, #revenue" },
);
}2. Auto-detect by beam-id / beam-item-id (no targets needed)
export function refreshDashboard(ctx: BeamContext<Env>) {
// Client automatically finds elements by beam-id or beam-item-id
return ctx.render([
<div beam-id="stats">Visits: {visits}</div>,
<div beam-id="users">Users: {users}</div>,
<div beam-id="revenue">Revenue: ${revenue}</div>,
]);
}3. Mixed approach
export function updateDashboard(ctx: BeamContext<Env>) {
return ctx.render(
[
<div>Header content</div>, // Uses explicit target
<div beam-id="content">Main content</div>, // Auto-detected by beam-id
],
{ target: "#header" }, // Only first item gets explicit target
);
}Target Resolution Order:
- Explicit target from comma-separated list (by index)
- Identity from the HTML fragment's root element (
beam-idorbeam-item-id) - Frontend fallback (
beam-targeton the triggering element) - Skip if no target found
Notes:
beam-targetaccepts any valid CSS selector (e.g.#id,.class,[attr=value]). Using#idtargets is still fully supported.- Auto-targeting (step 2) intentionally does not use plain
id="..."anymore; it uses onlybeam-id/beam-item-id. - When an explicit target is used and the server returns a single root element that has the same
beam-id/beam-item-idas the target, Beam unwraps it and swaps only the target’s inner content. This prevents accidentally nesting the component inside itself.
Exclusion: Use !selector to explicitly skip an item:
ctx.render(
[<Box1 />, <Box2 />, <Box3 />],
{ target: "#a, !#skip, #c" }, // Box2 is skipped
);Async Components
ctx.render() fully supports HonoX async components:
// Async component that fetches data
async function UserCard({ userId }: { userId: string }) {
const user = await db.getUser(userId); // Async data fetch
return (
<div class="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
// Use directly in ctx.render() - no wrapper needed
export function loadUser(
ctx: BeamContext<Env>,
{ id }: Record<string, unknown>,
) {
return ctx.render(<UserCard userId={id as string} />, { target: "#user" });
}
// Works with arrays too
export function loadUsers(ctx: BeamContext<Env>) {
return ctx.render(
[<UserCard userId="1" />, <UserCard userId="2" />, <UserCard userId="3" />],
{ target: "#user1, #user2, #user3" },
);
}
// Mixed sync and async
export function loadDashboard(ctx: BeamContext<Env>) {
return ctx.render([
<div>Static header</div>, // Sync
<UserCard userId="current" />, // Async
<StatsWidget />, // Async
]);
}Async components are awaited automatically - no manual Promise.resolve() or helper functions needed.
Attribute Reference
Actions
| Attribute | Description | Example |
| --------------------- | ------------------------------------------------------------- | ------------------------------------------- |
| beam-action | Action name to call | beam-action="increment" |
| beam-target | CSS selector for where to render response | beam-target="#counter" |
| beam-data-* | Pass data to the action | beam-data-id="123" |
| beam-include | Include values from inputs by beam-id, id, or name | beam-include="name,email,age" |
| beam-swap | How to swap content: replace, append, prepend, delete | beam-swap="replace" |
| beam-confirm | Show confirmation dialog before action | beam-confirm="Delete this item?" |
| beam-confirm-prompt | Require typing text to confirm | beam-confirm-prompt="Type DELETE\|DELETE" |
| beam-instant | Trigger on mousedown instead of click | beam-instant |
| beam-disable | Disable element(s) during request | beam-disable or beam-disable="#btn" |
| beam-placeholder | Show placeholder in target while loading | beam-placeholder="<p>Loading...</p>" |
| beam-push | Push URL to browser history after action | beam-push="/new-url" |
| beam-replace | Replace current URL in history | beam-replace="?page=2" |
Swap notes:
replacereplacestarget.innerHTML(no DOM diff), then tries to preserve UX:- Keeps focused input caret/selection when possible.
- Reinserts elements marked with
beam-keep(matched bybeam-id,beam-item-id,id, or inputname). - If Alpine.js is present on the page, initializes any newly inserted DOM (
Alpine.initTree).
Swap transitions (optional):
Add beam-swap-transition on the target element to animate after swaps:
<div id="results" beam-swap-transition="fade"></div>Supported values: fade, slide, scale.
Modals & Drawers
| Attribute | Description | Example |
| ------------------ | ------------------------------------------------- | -------------------------------------- |
| beam-modal | Action to call and display result in modal | beam-modal="editUser" |
| beam-drawer | Action to call and display result in drawer | beam-drawer="openCart" |
| beam-size | Size for modal/drawer: small, medium, large | beam-size="large" |
| beam-position | Drawer position: left, right | beam-position="left" |
| beam-placeholder | HTML to show while loading | beam-placeholder="<p>Loading...</p>" |
| beam-close | Close the current modal/drawer when clicked | beam-close |
Modals and drawers can also be returned from beam-action using context helpers:
// Modal with options
return ctx.modal(render(<MyModal />), { size: "large", spacing: 20 });
// Drawer with options
return ctx.drawer(render(<MyDrawer />), { position: "left", size: "medium" });Forms
| Attribute | Description | Example |
| ------------- | -------------------------------------- | ------------------------------- |
| beam-action | Action to call on submit (on <form>) | <form beam-action="saveUser"> |
| beam-reset | Reset form after successful submit | beam-reset |
Loading States
| Attribute | Description | Example |
| ---------------------- | ------------------------------------ | --------------------------------- |
| beam-loading-for | Show element while action is loading | beam-loading-for="saveUser" |
| beam-loading-for="*" | Show for any loading action | beam-loading-for="*" |
| beam-loading-data-* | Match specific parameters | beam-loading-data-id="123" |
| beam-loading-class | Add class while loading | beam-loading-class="opacity-50" |
| beam-loading-remove | Hide element while NOT loading | beam-loading-remove |
Validation
| Attribute | Description | Example |
| --------------- | ---------------------------------------------- | ------------------------------ |
| beam-validate | Target selector to update with validation | beam-validate="#email-error" |
| beam-watch | Event to trigger validation: input, change | beam-watch="input" |
| beam-debounce | Debounce delay in milliseconds | beam-debounce="300" |
Input Watchers
| Attribute | Description | Example |
| -------------------- | ----------------------------------------------------------- | ----------------------------------- |
| beam-watch | Event to trigger action: input, change | beam-watch="input" |
| beam-debounce | Debounce delay in milliseconds | beam-debounce="300" |
| beam-throttle | Throttle interval in milliseconds (alternative to debounce) | beam-throttle="100" |
| beam-watch-if | Condition that must be true to trigger | beam-watch-if="value.length >= 3" |
| beam-cast | Cast input value: number, integer, boolean, trim | beam-cast="number" |
| beam-loading-class | Add class to input while request is in progress | beam-loading-class="loading" |
| beam-keep | Prevent element from being replaced during updates | beam-keep |
Dirty Form Tracking
| Attribute | Description | Example |
| ---------------------- | --------------------------------------------- | --------------------------------- |
| beam-dirty-track | Enable dirty tracking on a form | <form beam-dirty-track> |
| beam-dirty-indicator | Show element when form is dirty | beam-dirty-indicator="#my-form" |
| beam-dirty-class | Toggle class instead of visibility | beam-dirty-class="has-changes" |
| beam-warn-unsaved | Warn before leaving page with unsaved changes | <form beam-warn-unsaved> |
| beam-revert | Button to revert form to original values | beam-revert="#my-form" |
| beam-show-if-dirty | Show element when form is dirty | beam-show-if-dirty="#my-form" |
| beam-hide-if-dirty | Hide element when form is dirty | beam-hide-if-dirty="#my-form" |
Conditional Form Fields
| Attribute | Description | Example |
| ------------------ | ------------------------------------------ | ------------------------------------------ |
| beam-enable-if | Enable field when condition is true | beam-enable-if="#subscribe:checked" |
| beam-disable-if | Disable field when condition is true | beam-disable-if="#country[value='']" |
| beam-visible-if | Show field when condition is true | beam-visible-if="#source[value='other']" |
| beam-hidden-if | Hide field when condition is true | beam-hidden-if="#premium:checked" |
| beam-required-if | Make field required when condition is true | beam-required-if="#business:checked" |
Deferred Loading
| Attribute | Description | Example |
| ------------- | ----------------------------------------- | ---------------------------- |
| beam-defer | Load content when element enters viewport | beam-defer |
| beam-action | Action to call (used with beam-defer) | beam-action="loadComments" |
Polling
| Attribute | Description | Example |
| --------------- | ------------------------------ | ------------------------- |
| beam-poll | Enable polling on this element | beam-poll |
| beam-interval | Poll interval in milliseconds | beam-interval="5000" |
| beam-action | Action to call on each poll | beam-action="getStatus" |
Hungry Elements
| Attribute | Description | Example |
| ------------- | -------------------------------------------------- | ------------- |
| beam-hungry | Auto-update when any response contains matching ID | beam-hungry |
Out-of-Band Updates
| Attribute | Description | Example |
| ------------ | ----------------------------------------------- | ------------------------------- |
| beam-touch | Update additional elements (on server response) | beam-touch="#sidebar,#footer" |
Optimistic UI
| Attribute | Description | Example |
| ----------------- | ------------------------------------------------- | ---------------------------------------- |
| beam-optimistic | Immediately update with this HTML before response | beam-optimistic="<div>Saving...</div>" |
Preloading & Caching
| Attribute | Description | Example |
| -------------- | ---------------------------------- | ---------------------- |
| beam-preload | Preload on hover: hover, mount | beam-preload="hover" |
| beam-cache | Cache duration in seconds | beam-cache="60" |
Infinite Scroll
| Attribute | Description | Example |
| ---------------- | ---------------------------------------------------------- | ------------------------ |
| beam-infinite | Load more when scrolled near bottom (auto-trigger) | beam-infinite |
| beam-load-more | Load more on click (manual trigger) | beam-load-more |
| beam-action | Action to call for next page | beam-action="loadMore" |
| beam-item-id | Unique ID for list items (deduplication + fresh data sync) | beam-item-id={item.id} |
Navigation Feedback
| Attribute | Description | Example |
| ---------------- | ----------------------------------------------------------- | ---------------- |
| beam-nav | Mark container as navigation (children get .beam-current) | <nav beam-nav> |
| beam-nav-exact | Only match exact URL paths | beam-nav-exact |
Offline Detection
| Attribute | Description | Example |
| ---------------------- | ------------------------------------------- | -------------------------------------- |
| beam-offline | Show element when offline, hide when online | beam-offline |
| beam-offline-class | Toggle class instead of visibility | beam-offline-class="offline-warning" |
| beam-offline-disable | Disable element when offline | beam-offline-disable |
Conditional Show/Hide
| Attribute | Description | Example |
| ------------------ | -------------------------------------------- | -------------------------- |
| beam-switch | Watch this field and control target elements | beam-switch=".options" |
| beam-show-for | Show when switch value matches | beam-show-for="premium" |
| beam-hide-for | Hide when switch value matches | beam-hide-for="free" |
| beam-enable-for | Enable when switch value matches | beam-enable-for="admin" |
| beam-disable-for | Disable when switch value matches | beam-disable-for="guest" |
Auto-submit Forms
| Attribute | Description | Example |
| ----------------- | ---------------------------------- | --------------------- |
| beam-autosubmit | Submit form when any field changes | beam-autosubmit |
| beam-debounce | Debounce delay in milliseconds | beam-debounce="300" |
Boost Links
| Attribute | Description | Example |
| ---------------- | -------------------------------------------- | ------------------- |
| beam-boost | Upgrade links to AJAX (on container or link) | <main beam-boost> |
| beam-boost-off | Exclude specific links from boosting | beam-boost-off |
Keep Elements
| Attribute | Description | Example |
| ----------- | ----------------------------------- | ------------------- |
| beam-keep | Preserve element during DOM updates | <video beam-keep> |
Client-Side Reactive State (No Server Round-Trip)
Beam supports a single client-side UI model: reactive state + declarative bindings. Use beam-state to define state, beam-show / beam-class / beam-text / beam-attr-* to bind UI, and beam-click / beam-model / beam-state-toggle to mutate state.
Reactive State
Fine-grained reactivity for UI components (carousels, tabs, accordions) without server round-trips.
| Attribute | Description | Example |
| ---------------- | --------------------------------------------------------- | ---------------------------------- |
| beam-state | Declare reactive state (JSON, key-value, or simple value) | beam-state="tab: 0; total: 5" |
| beam-id | Name the state for cross-component access | beam-id="cart" |
| beam-state-ref | Reference a named state from elsewhere | beam-state-ref="cart" |
| beam-text | Bind text content to expression | beam-text="count" |
| beam-attr-* | Bind any attribute to expression | beam-attr-disabled="count === 0" |
| beam-show | Show/hide element based on expression | beam-show="open" |
| beam-class | Toggle classes (simplified or JSON syntax) | beam-class="active: tab === 0" |
| beam-click | Click handler that mutates state | beam-click="open = !open" |
| beam-state-toggle | Toggle (or set) a state property (sugar) | beam-state-toggle="open" |
| beam-model | Two-way binding for inputs | beam-model="firstName" |
Swap Modes
Control how content is inserted into the target element:
| Mode | Description |
| --------- | ------------------------------------------------------------------------------------------- |
| replace | Replace target HTML (default) while preserving focus and beam-keep elements when possible |
| append | Add to the end of target |
| prepend | Add to the beginning of target |
| delete | Remove the target element |
<!-- Append new items to a list -->
<button beam-action="addItem" beam-swap="append" beam-target="#items">
Add Item
</button>
<!-- Replace update -->
<button beam-action="refresh" beam-swap="replace" beam-target="#content">
Refresh
</button>Loading States
Global Loading
Show a loader for any action:
<div beam-loading-for="*" class="spinner">Loading...</div>Per-Action Loading
Show a loader only for specific actions:
<button beam-action="save">
Save
<span beam-loading-for="save" class="spinner"></span>
</button>Parameter Matching
Show loading state only when parameters match:
<div class="item">
<span>Item 1</span>
<span beam-loading-for="deleteItem" beam-loading-data-id="1">
Deleting...
</span>
<button beam-action="deleteItem" beam-data-id="1">Delete</button>
</div>Loading Classes
Toggle a class instead of showing/hiding:
<div beam-loading-for="save" beam-loading-class="opacity-50">
Content that fades while saving
</div>Real-time Validation
Validate form fields as the user types:
<form beam-action="submitForm" beam-target="#result">
<input
name="email"
beam-validate="#email-error"
beam-watch="input"
beam-debounce="300"
/>
<div id="email-error"></div>
<button type="submit">Submit</button>
</form>The action receives _validate parameter indicating which field triggered validation:
export function submitForm(c) {
const data = await c.req.parseBody();
const validateField = data._validate;
if (validateField === "email") {
// Return just the validation feedback
if (data.email === "[email protected]") {
return <div class="error">Email already taken</div>;
}
return <div class="success">Email available</div>;
}
// Full form submission
return <div>Form submitted!</div>;
}Input Watchers
Trigger actions on input events without forms. Great for live search, auto-save, and real-time updates.
Basic Usage
<!-- Live search with debounce -->
<input
name="q"
placeholder="Search..."
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-debounce="300"
/>
<div id="results"></div>Throttle vs Debounce
Use beam-throttle for real-time updates (like range sliders) where you want periodic updates:
<!-- Range slider with throttle - updates every 100ms while dragging -->
<input
type="range"
name="price"
beam-action="updatePrice"
beam-target="#price-display"
beam-watch="input"
beam-throttle="100"
/>
<!-- Search with debounce - waits 300ms after user stops typing -->
<input
name="q"
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-debounce="300"
/>Conditional Triggers
Only trigger action when a condition is met:
<!-- Only search when 3+ characters are typed -->
<input
name="q"
placeholder="Type 3+ chars to search..."
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-watch-if="value.length >= 3"
beam-debounce="300"
/>The condition has access to value (current input value) and this (the element).
Type Casting
Cast input values before sending to the server:
<!-- Send as number instead of string -->
<input
type="range"
name="quantity"
beam-action="updateQuantity"
beam-cast="number"
beam-watch="input"
/>Cast types:
number- Parse as floatinteger- Parse as integerboolean- Convert "true"/"1"/"yes" to truetrim- Trim whitespace
Loading Feedback
Add a class to the input while the request is in progress:
<input
name="q"
placeholder="Search..."
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-loading-class="input-loading"
/>
<style>
.input-loading {
border-color: blue;
animation: pulse 1s infinite;
}
</style>Preventing Element Replacement
Use beam-keep to prevent an element from being replaced during updates. This keeps the element exactly as-is, preserving its state (focus, value, etc.):
<input
name="bio"
beam-action="validateBio"
beam-target="#bio-feedback"
beam-watch="input"
beam-keep
/>Since the input isn't replaced, focus and cursor position are naturally preserved.
Auto-Save on Blur
Trigger action when the user leaves the field:
<input
name="username"
beam-action="saveField"
beam-data-field="username"
beam-target="#save-status"
beam-watch="change"
beam-keep
/>
<div id="save-status">Not saved yet</div>Dirty Form Tracking
Track form changes and warn users before losing unsaved work.
Basic Usage
<form id="profile-form" beam-dirty-track>
<input name="username" value="johndoe" />
<input name="email" value="[email protected]" />
<button type="submit">Save</button>
</form>The form gets a beam-dirty attribute when modified.
Dirty Indicator
Show an indicator when the form has unsaved changes:
<h2>
Profile Settings
<span beam-dirty-indicator="#profile-form" class="unsaved-badge">*</span>
</h2>
<form id="profile-form" beam-dirty-track>
<!-- form fields -->
</form>
<style>
[beam-dirty-indicator] {
display: none;
color: orange;
}
</style>Revert Changes
Add a button to restore original values:
<form id="profile-form" beam-dirty-track>
<input name="username" value="johndoe" />
<input name="email" value="[email protected]" />
<button
type="button"
beam-revert="#profile-form"
beam-show-if-dirty="#profile-form"
>
Revert Changes
</button>
<button type="submit">Save</button>
</form>The revert button only shows when the form is dirty.
Unsaved Changes Warning
Warn users before navigating away with unsaved changes:
<form beam-dirty-track beam-warn-unsaved>
<input name="important-data" />
<button type="submit">Save</button>
</form>The browser will show a confirmation dialog if the user tries to close the tab or navigate away.
Conditional Visibility
Show/hide elements based on dirty state:
<form id="settings" beam-dirty-track>
<!-- Show when dirty -->
<div beam-show-if-dirty="#settings" class="warning">
You have unsaved changes
</div>
<!-- Hide when dirty -->
<div beam-hide-if-dirty="#settings">All changes saved</div>
</form>Conditional Form Fields
Enable, disable, show, or hide fields based on other field values—all client-side, no server round-trip.
Enable/Disable Fields
<label>
<input type="checkbox" id="subscribe" name="subscribe" />
Subscribe to newsletter
</label>
<!-- Enabled only when checkbox is checked -->
<input
type="email"
name="email"
placeholder="Enter your email..."
beam-enable-if="#subscribe:checked"
disabled
/>Show/Hide Fields
<select name="source" id="source">
<option value="">-- Select --</option>
<option value="google">Google</option>
<option value="friend">Friend</option>
<option value="other">Other</option>
</select>
<!-- Only visible when "other" is selected -->
<div beam-visible-if="#source[value='other']">
<label>Please specify</label>
<input type="text" name="source-other" />
</div>Required Fields
<label>
<input type="checkbox" id="business" name="is-business" />
This is a business account
</label>
<!-- Required only when checkbox is checked -->
<input
type="text"
name="company"
placeholder="Company name"
beam-required-if="#business:checked"
/>Condition Syntax
Conditions support:
:checked- Checkbox/radio is checked:disabled- Element is disabled:empty- Input has no value[value='x']- Input value equals 'x'[value!='x']- Input value not equals 'x'[value>'5']- Numeric comparison
<!-- Enable when country is selected -->
<select beam-disable-if="#country[value='']" name="state">
<!-- Show when amount is over 100 -->
<div beam-visible-if="#amount[value>'100']">
Large order discount applied!
</div>
</select>Deferred Loading
Load content only when it enters the viewport:
<div beam-defer beam-action="loadComments" class="placeholder">
Loading comments...
</div>The action is called once when the element becomes visible (with 100px margin).
Polling
Auto-refresh content at regular intervals:
<div beam-poll beam-interval="5000" beam-action="getNotifications">
<!-- Updated every 5 seconds -->
</div>Polling automatically stops when the element is removed from the DOM.
Hungry Elements
Elements marked with beam-hungry automatically update whenever any action returns HTML with a matching ID:
<!-- This badge updates when any action returns #cart-count -->
<span id="cart-count" beam-hungry>0</span>
<!-- Clicking this updates both #cart-result AND #cart-count -->
<button beam-action="addToCart" beam-target="#cart-result">Add to Cart</button>export function addToCart(c) {
const cartCount = getCartCount() + 1;
return (
<>
<div>Item added to cart!</div>
{/* This updates the hungry element */}
<span id="cart-count">{cartCount}</span>
</>
);
}Out-of-Band Updates
Update multiple elements from a single action using beam-touch:
export function updateDashboard(c) {
return (
<>
<div>Main content updated</div>
<div beam-touch="#sidebar">New sidebar content</div>
<div beam-touch="#notifications">3 new notifications</div>
</>
);
}Confirmation Dialogs
Require user confirmation before destructive actions:
<!-- Simple confirmation -->
<button beam-action="deletePost" beam-confirm="Delete this post?">
Delete
</button>
<!-- Require typing to confirm (for high-risk actions) -->
<button
beam-action="deleteAccount"
beam-confirm="This will permanently delete your account"
beam-confirm-prompt="Type DELETE to confirm|DELETE"
>
Delete Account
</button>Instant Click
Trigger actions on mousedown instead of click for ~100ms faster response:
<button beam-action="navigate" beam-instant>Next Page</button>Disable During Request
Prevent double-submissions by disabling elements during requests:
<!-- Disable the button itself -->
<button beam-action="save" beam-disable>Save</button>
<!-- Disable specific elements -->
<form beam-action="submit" beam-disable="#submit-btn, .form-inputs">
<input class="form-inputs" name="email" />
<button id="submit-btn" type="submit">Submit</button>
</form>Placeholders
Show loading content in the target while waiting for response:
<!-- Inline HTML placeholder -->
<button
beam-action="loadContent"
beam-target="#content"
beam-placeholder="<div class='skeleton'>Loading...</div>"
>
Load Content
</button>
<!-- Reference a template -->
<button
beam-action="loadContent"
beam-target="#content"
beam-placeholder="#loading-template"
>
Load Content
</button>
<template id="loading-template">
<div class="skeleton-loader">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
</template>Navigation Feedback
Automatically highlight current page links:
<nav beam-nav>
<a href="/home">Home</a>
<!-- Gets .beam-current when on /home -->
<a href="/products">Products</a>
<!-- Gets .beam-current when on /products/* -->
<a href="/about" beam-nav-exact>About</a>
<!-- Only exact match -->
</nav>Links also get .beam-active class while loading.
CSS classes:
.beam-current- Link matches current URL.beam-active- Action is in progress
Offline Detection
Show/hide content based on connection status:
<!-- Show warning when offline -->
<div beam-offline class="offline-banner">
You are offline. Changes won't be saved.
</div>
<!-- Disable buttons when offline -->
<button beam-action="save" beam-offline-disable>Save</button>The body also gets .beam-offline class when disconnected.
Conditional Show/Hide
Toggle element visibility based on form field values (no server round-trip):
<select name="plan" beam-switch=".plan-options">
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
<div class="plan-options" beam-show-for="free">
Free plan: 5 projects, 1GB storage
</div>
<div class="plan-options" beam-show-for="pro">
Pro plan: Unlimited projects, 100GB storage
</div>
<div class="plan-options" beam-show-for="enterprise">
Enterprise: Custom limits, dedicated support
</div>
<!-- Enable/disable based on selection -->
<button class="plan-options" beam-enable-for="pro,enterprise">
Advanced Settings
</button>Auto-submit Forms
Automatically submit forms when fields change (great for filters):
<form
beam-action="filterProducts"
beam-target="#products"
beam-autosubmit
beam-debounce="300"
>
<input name="search" placeholder="Search..." />
<select name="category">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select name="sort">
<option value="newest">Newest</option>
<option value="price">Price</option>
</select>
</form>
<div id="products">
<!-- Results update automatically as user changes filters -->
</div>Boost Links
Upgrade regular links to use AJAX navigation:
<!-- Boost all links in a container -->
<main beam-boost>
<a href="/page1">Page 1</a>
<!-- Now uses AJAX -->
<a href="/page2">Page 2</a>
<!-- Now uses AJAX -->
<a href="/external.com" beam-boost-off>External</a>
<!-- Not boosted -->
</main>
<!-- Or boost individual links -->
<a href="/about" beam-boost beam-target="#content">About</a>Boosted links:
- Fetch pages via AJAX
- Update the specified target (default:
body) - Push to browser history
- Update page title
- Fall back to normal navigation on error
History Management
Update browser URL after actions:
<!-- Push new URL to history -->
<button beam-action="loadTab" beam-data-tab="settings" beam-push="/settings">
Settings
</button>
<!-- Replace current URL (no new history entry) -->
<button beam-action="filter" beam-data-page="2" beam-replace="?page=2">
Page 2
</button>Keep Elements
Preserve specific elements during DOM updates (useful for video players, animations):
<div id="content">
<!-- This video keeps playing during updates -->
<video beam-keep src="video.mp4" autoplay></video>
<!-- This content gets updated -->
<div id="comments">
<!-- Comments here -->
</div>
</div>Client-Side Reactive State
Beam uses a single client-side UI model: reactive state + declarative bindings.
Reactive State
Fine-grained reactivity for UI components like carousels, tabs, accordions, and modals. All declarative via HTML attributes.
beam-state Syntax Options
1. Simple value with beam-id (property name comes from beam-id):
<div beam-state="false" beam-id="open">
<button type="button" beam-state-toggle="open">Toggle</button>
<div beam-show="open">Content</div>
</div>
<div beam-state="0" beam-id="count">
<button beam-click="count++">
Clicked <span beam-text="count"></span> times
</button>
</div>2. Key-value pairs (semicolon-separated):
<div beam-state="tab: 0; total: 5">
<button beam-click="tab = (tab + 1) % total">Next</button>
<span beam-text="tab + 1"></span> of <span beam-text="total"></span>
</div>
<div beam-state="name: 'World'; greeting: 'Hello'">
<span beam-text="greeting + ', ' + name + '!'"></span>
</div>3. JSON (for arrays and nested objects):
<div beam-state='{"items": [1, 2, 3], "config": {"enabled": true}}'>...</div>beam-class Syntax Options
1. Simplified syntax (no JSON required):
<!-- Single class -->
<button beam-class="active: tab === 0">Tab 1</button>
<!-- Multiple classes (semicolon-separated) -->
<div
beam-class="text-red: hasError; text-green: !hasError; bold: important"
></div>2. Multiple classes with one condition (quote the class names):
<div
beam-class="'bg-green text-white shadow-lg': isActive; 'bg-gray text-dark': !isActive"
></div>3. JSON (backward compatible):
<button beam-class="{ active: tab === 0, highlight: selected }">
<div beam-class="{ 'text-green italic': !hasError, bold: important }"></div>
</button>Examples
<!-- Accordion -->
<div beam-state="open: false">
<button beam-click="open = !open">Toggle</button>
<div beam-show="open">
<p>Expanded content here...</p>
</div>
</div>
<!-- Tabs with simplified beam-class -->
<div beam-state="tab: 0">
<button beam-click="tab = 0" beam-class="active: tab === 0">Tab 1</button>
<button beam-click="tab = 1" beam-class="active: tab === 1">Tab 2</button>
<button beam-click="tab = 2" beam-class="active: tab === 2">Tab 3</button>
<div beam-show="tab === 0">Content for Tab 1</div>
<div beam-show="tab === 1">Content for Tab 2</div>
<div beam-show="tab === 2">Content for Tab 3</div>
</div>
<!-- Carousel -->
<div beam-state="slide: 0; total: 5">
<button beam-click="slide = (slide - 1 + total) % total">← Prev</button>
<span beam-text="slide + 1"></span> / <span beam-text="total"></span>
<button beam-click="slide = (slide + 1) % total">Next →</button>
</div>
<!-- Counter with attribute binding -->
<div beam-state="count: 0">
<button beam-click="count--" beam-attr-disabled="count === 0">-</button>
<span beam-text="count"></span>
<button beam-click="count++">+</button>
</div>
<!-- Status indicator with multiple classes -->
<div beam-state="status: 'idle'">
<div
beam-class="status-idle: status === 'idle'; status-loading: status === 'loading'; status-error: status === 'error'"
>
<span beam-text="status"></span>
</div>
</div>Named State (Cross-Component)
Share state between different parts of the page. Named states (with beam-id) persist across server-driven updates:
<!-- Cart state defined once -->
<div beam-state="count: 0" beam-id="cart">
Cart: <span beam-text="count"></span> items
</div>
<!-- Add to cart button elsewhere -->
<button beam-state-ref="cart" beam-click="count++">Add to Cart</button>Note: Named states persist when the DOM is updated by beam-action. This means reactive state is preserved even when server actions update the page.
JavaScript API
Access reactive state programmatically:
// Get named state
const cartState = beam.getState("cart");
cartState.count++; // Triggers reactive updates
// Get state by element
const el = document.querySelector("[beam-state]");
const state = beam.getState(el);
// Batch multiple updates
beam.batch(() => {
state.a = 1;
state.b = 2;
});Standalone Usage (No Beam Server)
The reactivity system can be used independently without the Beam WebSocket server:
// Just import reactivity - no server connection needed
import "@benqoder/beam/reactivity";This is useful for:
- Static sites that don't need server communication
- Adding reactivity to existing projects
- Using with other frameworks
The API is exposed on window.beamReactivity:
// When using standalone
const state = beamReactivity.getState("my-state");
state.count++;CSS
Or import the included CSS (swap transitions, modal/drawer styles):
import "@benqoder/beam/styles";
// or
import "@benqoder/beam/beam.css";Migration from Alpine.js
Before (Alpine.js):
<div x-data="{ open: false }">
<button @click="open = !open">Menu</button>
<div x-show="open" x-cloak>Content</div>
</div>After (Beam):
<div beam-state='{"open": false}'>
<button type="button" beam-state-toggle="open">Menu</button>
<div beam-show="open">Content</div>
</div>Before (Alpine.js dropdown):
<div x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open">Account</button>
<div x-show="open">Dropdown</div>
</div>After (Beam):
<div beam-state='{"open": false}'>
<button type="button" beam-state-toggle="open">Account</button>
<div beam-show="open">Dropdown</div>
</div>Server API
createBeam
Creates a Beam instance with handlers:
import { createBeam } from "@benqoder/beam";
const beam = createBeam<Env>({
actions: { increment, decrement, openModal, openCart },
});render
Utility to render JSX to HTML string:
import { render } from '@benqoder/beam'
const html = render(<div>Hello</div>)Vite Plugin Options
beamPlugin({
// Glob pattern for action handler files (must start with '/' for virtual modules)
actions: "/app/actions/*.tsx", // default
});TypeScript
Handler Types
import type { ActionHandler, ActionResponse, BeamContext } from '@benqoder/beam'
import { render } from '@benqoder/beam'
// Action that returns HTML string
const myAction: ActionHandler<Env> = async (ctx, params) => {
return '<div>Hello</div>'
}
// Action that returns ActionResponse with modal
const openModal: ActionHandler<Env> = async (ctx, params) => {
return ctx.modal(render(<div>Modal content</div>), { size: 'medium' })
}
// Action that returns ActionResponse with drawer
const openDrawer: ActionHandler<Env> = async (ctx, params) => {
return ctx.drawer(render(<div>Drawer content</div>), { position: 'right' })
}Virtual Module Types
Add to your app/vite-env.d.ts:
/// <reference types="@benqoder/beam/virtual" />Examples
Todo List
// actions/todos.tsx
let todos = ["Learn Beam", "Build something"];
export function addTodo(c) {
const text = c.req.query("text");
if (text) todos.push(text);
return <TodoList todos={todos} />;
}
export function deleteTodo(c) {
const index = parseInt(c.req.query("index"));
todos.splice(index, 1);
return <TodoList todos={todos} />;
}
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo, i) => (
<li>
{todo}
<button
beam-action="deleteTodo"
beam-data-index={i}
beam-target="#todos"
>
Delete
</button>
</li>
))}
</ul>
);
}<form beam-action="addTodo" beam-target="#todos" beam-reset>
<input name="text" placeholder="New todo" />
<button type="submit">Add</button>
</form>
<div id="todos">
<!-- Todo list renders here -->
</div>Live Search
// actions/search.tsx
export function search(c) {
const query = c.req.query("q") || "";
const results = searchDatabase(query);
return (
<ul>
{results.map((item) => (
<li>{item.name}</li>
))}
</ul>
);
}<input
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-debounce="300"
name="q"
placeholder="Search..."
/>
<div id="results"></div>Shopping Cart with Badge
// actions/cart.tsx
let cart = [];
export function addToCart(c) {
const product = c.req.query("product");
cart.push(product);
return (
<>
<div>Added {product} to cart!</div>
<span id="cart-badge">{cart.length}</span>
</>
);
}<span id="cart-badge" beam-hungry>0</span>
<button
beam-action="addToCart"
beam-data-product="Widget"
beam-target="#message"
>
Add Widget
</button>
<div id="message"></div>Session Management
Beam provides automatic session management with a simple ctx.session API. No boilerplate middleware required.
Quick Start
- Enable sessions in vite.config.ts:
beamPlugin({
actions: "/app/actions/*.tsx",
session: true, // Enable with defaults (cookie storage)
});- Add SESSION_SECRET to wrangler.toml:
[vars]
SESSION_SECRET = "your-secret-key-change-in-production"- Use in actions:
// app/actions/cart.tsx
export async function addToCart(ctx: BeamContext<Env>, data) {
const cart = await ctx.session.get<CartItem[]>('cart') || []
cart.push({ productId: data.productId, qty: data.qty })
await ctx.session.set('cart', cart)
return <CartBadge count={cart.length} />
}- Use in routes:
// app/routes/products/index.tsx
export default createRoute(async (c) => {
const { session } = c.get('beam')
const cart = await session.get<CartItem[]>('cart') || []
return c.html(
<Layout cartCount={cart.length}>
<ProductList />
</Layout>
)
})Session API
// Get a value (returns null if not set)
const cart = await ctx.session.get<CartItem[]>("cart");
// Set a value
await ctx.session.set("cart", [{ productId: "123", qty: 1 }]);
// Delete a value
await ctx.session.delete("cart");Storage Options
Cookie Storage (Default)
Session data is stored in a signed cookie. Good for small data (~4KB limit).
beamPlugin({
session: true, // Uses cookie storage
});
// Or with custom options:
beamPlugin({
session: {
secretEnvKey: "MY_SECRET", // Default: 'SESSION_SECRET'
cookieName: "my_sid", // Default: 'beam_sid'
maxAge: 86400, // Default: 1 year (in seconds)
},
});KV Storage (For WebSocket Actions)
Cookie storage is read-only in WebSocket context. For actions that modify session data via WebSocket, use KV storage:
// vite.config.ts
beamPlugin({
session: { storage: "/app/session-storage.ts" },
});// app/session-storage.ts
import { KVSession } from "@benqoder/beam";
export default (sessionId: string, env: { KV: KVNamespace }) =>
new KVSession(sessionId, env.KV);# wrangler.toml
[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"
[vars]
SESSION_SECRET = "your-secret-key"Architecture
Request comes in
↓
Read session ID from signed cookie (beam_sid)
↓
Create session adapter with sessionId + env
↓
ctx.session.get('cart') → adapter.get()
ctx.session.set('cart') → adapter.set()Two components:
- Session ID: Always stored in a signed cookie (
beam_sid) - Session data: Configurable storage (cookies by default, KV optional)
Key Points
- Zero boilerplate - Just enable in vite.config.ts and use
ctx.session - Works in actions - Use
ctx.session.get/set/delete - Works in routes - Use
c.get('beam').session.get/set/delete - Cookie storage limit - ~4KB total size
- WebSocket limitation - Cookie storage is read-only in WebSocket (use KV for write operations)
- Signed cookies - Session ID is cryptographically signed to prevent tampering
Security: WebSocket Authentication
Beam uses in-band authentication to prevent Cross-Site WebSocket Hijacking (CSWSH) attacks. This is the pattern recommended by capnweb.
The Problem
WebSocket connections in browsers:
- Always permit cross-site connections (no CORS for WebSocket)
- Automatically send cookies with the upgrade request
- Cannot use custom headers for authentication
This means a malicious site could open a WebSocket to your server, and the browser would send your cookies, authenticating the attacker.
The Solution: In-Band Authentication
Instead of relying on cookies, Beam requires clients to authenticate explicitly:
- Server generates a short-lived token (embedded in same-origin page)
- WebSocket connects unauthenticated (gets
PublicApi) - Client calls
authenticate(token)to get the full API - Malicious sites can't get the token (CORS blocks page requests)
Setup
1. Enable Sessions (Required)
The auth token is tied to sessions:
// vite.config.ts
beamPlugin({
actions: "/app/actions/*.tsx",
session: true, // Uses env.SESSION_SECRET
});2. Use authMiddleware
// app/server.ts
import { createApp } from "honox/server";
import { beam } from "virtual:beam";
const app = createApp({
init(app) {
app.use("*", beam.authMiddleware()); // Generates token
beam.init(app);
},
});
export default app;3. Inject Token in Layout
// app/routes/_renderer.tsx
import { jsxRenderer } from "hono/jsx-renderer";
export default jsxRenderer((c, { children }) => {
const token = c.get("beamAuthToken");
return (
<html>
<head>
<meta name="beam-token" content={token} />
<script type="module" src="/app/client.ts"></script>
</head>
<body>{children}</body>
</html>
);
});Or use the helper:
import { beamTokenMeta } from "@benqoder/beam";
import { Raw } from "hono/html";
<head>
<Raw html={beamTokenMeta(c.get("beamAuthToken"))} />
</head>;4. Set Environment Variable
# .dev.vars (local) or Cloudflare dashboard (production)
SESSION_SECRET=your-secret-key-at-least-32-charsHow It Works
| Step | What Happens |
| ----------------------- | --------------------------------------------------- |
| 1. Page Load | Server generates 5-minute token, embeds in HTML |
| 2. Client Connects | WebSocket opens, gets PublicApi (unauthenticated) |
| 3. Client Authenticates | Calls publicApi.authenticate(token) |
| 4. Server Validates | Verifies signature, expiration, session match |
| 5. Server Returns | Full BeamServer API (authenticated) |
Security Properties
| Attack | Result |
| -------------------- | -------------------------------------------------- |
| Cross-site WebSocket | Can connect, but authenticate() fails (no token) |
| Stolen token | Expires in 5 minutes, tied to session ID |
| Replay attack | Token is single-use per session |
| Token tampering | HMAC-SHA256 signature verification fails |
Token Details
- Algorithm: HMAC-SHA256
- Lifetime: 5 minutes (configurable)
- Payload:
{ sid: sessionId, uid: userId, exp: timestamp } - Format:
base64(payload).base64(signature)
Generating Tokens Manually
If you need to generate tokens outside the middleware:
const token = await beam.generateAuthToken(ctx);Programmatic API
Call actions directly from JavaScript using window.beam:
// Call action with RPC only (no DOM update)
const response = await window.beam.logout();
// Call action and swap HTML into target
await window.beam.getCartBadge({}, "#cart-count");
// With swap mode
await window.beam.loadMoreProducts(
{ page: 2 },
{ target: "#products", swap: "append" },
);
// Full options object
await window.beam.addToCart(
{ productId: 123 },
{
target: "#cart-badge",
swap: "replace", // 'replace' | 'append' | 'prepend'
},
);API Signature
window.beam.actionName(data?, options?) → Promise<ActionResponse>
// data: Record<string, unknown> - parameters passed to the action
// options: string | { target?: string, swap?: string }
// - string shorthand: treated as target selector
// - object: full options with target and swap mode
// ActionResponse: { html?: string | string[], script?: string, redirect?: string, target?: string }Response Handling
The API automatically handles:
- HTML swapping: If
options.targetis provided, swaps HTML into the target element - Script execution: If response contains a script, executes it
- Redirects: If response contains a redirect URL, navigates to it
// Server returns script - executed automatically
await window.beam.showNotification({ message: "Hello!" });
// Server returns redirect - navigates automatically
await window.beam.logout(); // ctx.redirect('/login')
// Server returns HTML + script - both handled
await window.beam.addToCart({ id: 42 }, "#cart-badge");Utility Methods
window.beam.showToast(message, type?) // Show toast notification
window.beam.closeModal() // Close current modal
window.beam.closeDrawer() // Close current drawer
window.beam.clearCache() // Clear action cache
window.beam.isOnline() // Check connection status
window.beam.getSession() // Get current session ID
window.beam.clearScrollState() // Clear saved scroll stateServer Redirects
Actions can trigger client-side redirects using ctx.redirect():
// app/actions/auth.tsx
export function logout(ctx: BeamContext<Env>) {
// Clear session, etc.
return ctx.redirect("/login");
}
export function requireAuth(ctx: BeamContext<Env>) {
const user = ctx.session.get("user");
if (!user) {
return ctx.redirect("/login?next=" + encodeURIComponent(ctx.req.url));
}
// Continue with action...
}The redirect is handled automatically on the client side, whether triggered via:
- Button/link click (
beam-action) - Form submission
- Programmatic call (
window.beam.actionName())
State Preservation
Pagination URL State
For pagination, users expect the back button to return them to the same page. Use beam-replace to update the URL without a full page reload:
<button
beam-action="getProducts"
beam-params='{"page": 2}'
beam-target="#product-list"
beam-replace="?page=2"
>
Page 2
</button>When the user navigates away and clicks back, the browser loads the URL with ?page=2, and your page can read this to show the correct page.
// In your route handler
const page = parseInt(c.req.query("page") || "1");
const products = await getProducts(page);Infinite Scroll & Load More State
For infinite scroll and load more, Beam automatically preserves:
- Loaded content: All items that were loaded
- Scroll position: Where the user was on the page
This happens automatically when using beam-infinite or beam-load-more. The state is stored in the browser's sessionStorage with a 30-minute TTL.
Infinite Scroll (auto-trigger on visibility)
<div id="product-list" class="product-grid">
{products.map((p) => (
<ProductCard product={p} />
))}
{hasMore && (
<div
class="load-more-sentinel"
beam-infinite
beam-action="loadMoreInfinite"
beam-params='{"page": 2}'
beam-target="#product-list"
/>
)}
</div>Load More Button (click to trigger)
<div id="product-list" class="product-grid">
{products.map((p) => (
<ProductCard product={p} />
))}
{hasMore && (
<button
class="load-more-btn"
beam-load-more
beam-action="loadMoreProducts"
beam-params='{"page": 2}'
beam-target="#product-list"
>
Load More
</button>
)}
</div>Both patterns work the same way:
- User loads more content (scroll or click)
- User navigates away (clicks a product)
- User clicks the back button
- Content and scroll position are restored
- Fresh server data is applied over cached items (using
beam-item-id)
Keeping Items Fresh with beam-item-id
When restoring from cache, the server still renders fresh Page 1 data. To sync fresh data with cached items, add beam-item-id to your list items:
<!-- Any list item with a unique identifier -->
<div class="product-card" beam-item-id={product.id}>...</div>
<div class="comment" beam-item-id={comment.id}>...</div>
<article beam-item-id={post.slug}>...</article>On back navigation:
- Cache is restored (all loaded items + scroll position)
- Fresh server items are applied over matching cached items
- Server data takes precedence for items in both
This ensures Page 1 items always have fresh data (prices, stock, etc.) while preserving the full scroll state.
Automatic Deduplication
When using beam-item-id, Beam automatically handles duplicate items when appending or prepending content:
- Duplicates are replaced: If an item with the same
beam-item-idalready exists, it's updated with fresh data - No double-insertion: The duplicate is removed from incoming HTML after updating
This is useful when:
- New items are inserted while the user is scrolling (causing offset shifts)
- Real-time updates add items that might already exist
- Race conditions between pagination requests
<!-- Items with beam-item-id are automatically deduplicated -->
<div id="feed" class="post-list">
<article beam-item-id="post-123">...</article>
<article beam-item-id="post-124">...</article>
</div>If incoming content contains post-123 again, the existing one is updated with fresh data instead of adding a duplicate.
The action should return the new items plus the next trigger (sentinel or button):
// app/actions/loadMore.tsx
export async function loadMoreProducts(ctx, { page }) {
const items = await getItems(page);
const hasMore = await hasMoreItems(page);
return (
<>
{items.map((item) => (
<ProductCard product={item} />
))}
{hasMore ? (
<button
beam-load-more
beam-action="loadMoreProducts"
beam-params={JSON.stringify({ page: page + 1 })}
beam-target="#product-list"
>
Load More
</button>
) : (
<p>No more items</p>
)}
</>
);
}Clearing Scroll State
To manually clear the saved scroll state:
window.beam.clearScrollState();This is useful when you want to force a fresh load, such as after a filter change or form submission.
Browser Support
Beam requires modern browsers with WebSocket support:
- Chrome 16+
- Firefox 11+
- Safari 7+
- Edge 12+
License
MIT
