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.
npm install @benqoder/beamA 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.
- 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()
``bash`
npm install @benqoder/beam
`typescript
// vite.config.ts
import { beamPlugin } from "@benqoder/beam/vite";
export default defineConfig({
plugins: [
beamPlugin({
actions: "./actions/*.tsx",
}),
],
});
`
`typescript
// app/server.ts
import { createApp } from "honox/server";
import { beam } from "virtual:beam";
const app = createApp({
init: beam.init,
});
export default app;
`
`typescript`
// app/client.ts
import "@benqoder/beam/client";
`tsx`
// app/actions/counter.tsx
export function increment(c) {
const count = parseInt(c.req.query("count") || "0");
return Count: {count + 1};
}
`html`Count: 0
---
Actions are server functions that return HTML. They're the primary way to handle user interactions.
`tsx`
// app/actions/demo.tsx
export function greet(c) {
const name = c.req.query("name") || "World";
return Hello, {name}!;
}
`html`
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):
`html
beam-action="saveUser"
beam-include="name,email,age,subscribe"
beam-data-source="form"
beam-target="#result"
>
Save
The action receives merged params with proper type conversion:
`json
{
"source": "form",
"name": "Ben",
"email": "ben@example.com",
"age": 30,
"subscribe": true
}
`Type conversion:
-
checkbox → boolean (checked state)
- number/range → number
- All others → string$3
Two ways to open modals:
1.
beam-modal attribute - Explicitly opens the action result in a modal, with optional placeholder:`html
beam-modal="confirmDelete"
beam-data-id="123"
beam-size="small"
beam-placeholder="Loading..."
>
Delete Item
`2.
beam-action with ctx.modal() - Action decides to return a modal:`tsx
// app/actions/confirm.tsx
export function confirmDelete(
ctx: BeamContext,
{ id }: Record,
) {
return ctx.modal(
Confirm Delete
Are you sure you want to delete item {id}?
,
{ size: "small" },
);
}
`ctx.modal() accepts JSX directly - no wrapper function needed. Options: size ('small' | 'medium' | 'large'), spacing (padding in pixels).`html
`$3
Two ways to open drawers:
1.
beam-drawer attribute - Explicitly opens in a drawer:`html
beam-drawer="openCart"
beam-position="right"
beam-size="medium"
beam-placeholder="Loading cart..."
>
Open Cart
`2.
beam-action with ctx.drawer() - Action returns a drawer:`tsx
// app/actions/cart.tsx
export function openCart(ctx: BeamContext) {
return ctx.drawer(
Shopping Cart
{/ Cart contents /}
,
{ position: "right", size: "medium" },
);
}
`ctx.drawer() accepts JSX directly. Options: position ('left' | 'right'), size ('small' | 'medium' | 'large'), spacing (padding in pixels).`html
`$3
Update multiple targets in a single action response using
ctx.render() with arrays:1. Explicit targets (comma-separated)
`tsx
export function refreshDashboard(ctx: BeamContext) {
return ctx.render(
[
Visits: {visits},
Users: {users},
Revenue: ${revenue},
],
{ target: "#stats, #users, #revenue" },
);
}
`2. Auto-detect by beam-id / beam-item-id (no targets needed)
`tsx
export function refreshDashboard(ctx: BeamContext) {
// Client automatically finds elements by beam-id or beam-item-id
return ctx.render([
Visits: {visits},
Users: {users},
Revenue: ${revenue},
]);
}
`3. Mixed approach
`tsx
export function updateDashboard(ctx: BeamContext) {
return ctx.render(
[
Header content, // Uses explicit target
Main content, // Auto-detected by beam-id
],
{ target: "#header" }, // Only first item gets explicit target
);
}
`Target Resolution Order:
1. Explicit target from comma-separated list (by index)
2. Identity from the HTML fragment's root element (
beam-id or beam-item-id)
3. Frontend fallback (beam-target on the triggering element)
4. Skip if no target foundNotes:
-
beam-target accepts any valid CSS selector (e.g. #id, .class, [attr=value]). Using #id targets is still fully supported.
- Auto-targeting (step 2) intentionally does not use plain id="..." anymore; it uses only beam-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-id as 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:`tsx
ctx.render(
[ , , ],
{ target: "#a, !#skip, #c" }, // Box2 is skipped
);
`$3
ctx.render() fully supports HonoX async components:`tsx
// Async component that fetches data
async function UserCard({ userId }: { userId: string }) {
const user = await db.getUser(userId); // Async data fetch
return (
{user.name}
{user.email}
);
}// Use directly in ctx.render() - no wrapper needed
export function loadUser(
ctx: BeamContext,
{ id }: Record,
) {
return ctx.render( , { target: "#user" });
}
// Works with arrays too
export function loadUsers(ctx: BeamContext) {
return ctx.render(
[ , , ],
{ target: "#user1, #user2, #user3" },
);
}
// Mixed sync and async
export function loadDashboard(ctx: BeamContext) {
return ctx.render([
Static header, // Sync
, // Async
, // Async
]);
}
`Async components are awaited automatically - no manual
Promise.resolve() or helper functions needed.---
Attribute Reference
$3
| 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="Loading...
" |
| 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:
-
replace replaces target.innerHTML (no DOM diff), then tries to preserve UX:
- Keeps focused input caret/selection when possible.
- Reinserts elements marked with beam-keep (matched by beam-id, beam-item-id, id, or input name).
- 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:`html
`Supported values:
fade, slide, scale.$3
| 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="Loading...
" |
| beam-close | Close the current modal/drawer when clicked | beam-close |Modals and drawers can also be returned from
beam-action using context helpers:`tsx
// Modal with options
return ctx.modal(render( ), { size: "large", spacing: 20 });// Drawer with options
return ctx.drawer(render( ), { position: "left", size: "medium" });
`$3
| Attribute | Description | Example |
| ------------- | -------------------------------------- | ------------------------------- |
|
beam-action | Action to call on submit (on ---
Boost Links
Upgrade regular links to use AJAX navigation:
`html
Page 1
Page 2
External
`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:
`html
`---
Keep Elements
Preserve specific elements during DOM updates (useful for video players, animations):
`html
`---
Client-Side Reactive State
Beam uses a single client-side UI model: reactive state + declarative bindings.
$3
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):
`html
Content
`2. Key-value pairs (semicolon-separated):
`html
of
`3. JSON (for arrays and nested objects):
`html
...
`#### beam-class Syntax Options
1. Simplified syntax (no JSON required):
`html
beam-class="text-red: hasError; text-green: !hasError; bold: important"
>2. Multiple classes with one condition (quote the class names):
`html
beam-class="'bg-green text-white shadow-lg': isActive; 'bg-gray text-dark': !isActive"
>3. JSON (backward compatible):
`html
`#### Examples
`html
Expanded content here...
Content for Tab 1
Content for Tab 2
Content for Tab 3
/
beam-class="status-idle: status === 'idle'; status-loading: status === 'loading'; status-error: status === 'error'"
>
#### Named State (Cross-Component)
Share state between different parts of the page. Named states (with
beam-id) persist across server-driven updates:`html
Cart: items
`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:
`javascript
// 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:
`typescript
// 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:`javascript
// When using standalone
const state = beamReactivity.getState("my-state");
state.count++;
`$3
Or import the included CSS (swap transitions, modal/drawer styles):
`typescript
import "@benqoder/beam/styles";
// or
import "@benqoder/beam/beam.css";
`$3
Before (Alpine.js):
`html
Content
`After (Beam):
`html
Content
`Before (Alpine.js dropdown):
`html
Dropdown
`After (Beam):
`html
Dropdown
`---
Server API
$3
Creates a Beam instance with handlers:
`typescript
import { createBeam } from "@benqoder/beam";const beam = createBeam({
actions: { increment, decrement, openModal, openCart },
});
`$3
Utility to render JSX to HTML string:
`typescript
import { render } from '@benqoder/beam'const html = render(
Hello)
`---
Vite Plugin Options
`typescript
beamPlugin({
// Glob pattern for action handler files (must start with '/' for virtual modules)
actions: "/app/actions/*.tsx", // default
});
`---
TypeScript
$3
`typescript
import type { ActionHandler, ActionResponse, BeamContext } from '@benqoder/beam'
import { render } from '@benqoder/beam'// Action that returns HTML string
const myAction: ActionHandler = async (ctx, params) => {
return 'Hello'
}
// Action that returns ActionResponse with modal
const openModal: ActionHandler = async (ctx, params) => {
return ctx.modal(render(Modal content), { size: 'medium' })
}
// Action that returns ActionResponse with drawer
const openDrawer: ActionHandler = async (ctx, params) => {
return ctx.drawer(render(Drawer content), { position: 'right' })
}
`$3
Add to your
app/vite-env.d.ts:`typescript
///
`---
Examples
$3
`tsx
// 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 ;
}
export function deleteTodo(c) {
const index = parseInt(c.req.query("index"));
todos.splice(index, 1);
return ;
}
function TodoList({ todos }) {
return (
{todos.map((todo, i) => (
{todo}
beam-action="deleteTodo"
beam-data-index={i}
beam-target="#todos"
>
Delete
))}
);
}
``html
`$3
`tsx
// actions/search.tsx
export function search(c) {
const query = c.req.query("q") || "";
const results = searchDatabase(query); return (
{results.map((item) => (
- {item.name}
))}
);
}
``html
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-debounce="300"
name="q"
placeholder="Search..."
/>
`$3
`tsx
// actions/cart.tsx
let cart = [];export function addToCart(c) {
const product = c.req.query("product");
cart.push(product);
return (
<>
Added {product} to cart!
{cart.length}
>
);
}
``html
0 beam-action="addToCart"
beam-data-product="Widget"
beam-target="#message"
>
Add Widget
`---
Session Management
Beam provides automatic session management with a simple
ctx.session API. No boilerplate middleware required.$3
1. Enable sessions in vite.config.ts:
`typescript
beamPlugin({
actions: "/app/actions/*.tsx",
session: true, // Enable with defaults (cookie storage)
});
`2. Add SESSION_SECRET to wrangler.toml:
`toml
[vars]
SESSION_SECRET = "your-secret-key-change-in-production"
`3. Use in actions:
`typescript
// app/actions/cart.tsx
export async function addToCart(ctx: BeamContext, data) {
const cart = await ctx.session.get('cart') || []
cart.push({ productId: data.productId, qty: data.qty })
await ctx.session.set('cart', cart)
return
}
`4. Use in routes:
`typescript
// app/routes/products/index.tsx
export default createRoute(async (c) => {
const { session } = c.get('beam')
const cart = await session.get('cart') || [] return c.html(
)
})
`$3
`typescript
// Get a value (returns null if not set)
const cart = await ctx.session.get("cart");// Set a value
await ctx.session.set("cart", [{ productId: "123", qty: 1 }]);
// Delete a value
await ctx.session.delete("cart");
`$3
#### Cookie Storage (Default)
Session data is stored in a signed cookie. Good for small data (~4KB limit).
`typescript
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:
`typescript
// vite.config.ts
beamPlugin({
session: { storage: "/app/session-storage.ts" },
});
``typescript
// app/session-storage.ts
import { KVSession } from "@benqoder/beam";export default (sessionId: string, env: { KV: KVNamespace }) =>
new KVSession(sessionId, env.KV);
``toml
wrangler.toml
[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"[vars]
SESSION_SECRET = "your-secret-key"
`$3
`
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)$3
- 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.
$3
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.
$3
Instead of relying on cookies, Beam requires clients to authenticate explicitly:
1. Server generates a short-lived token (embedded in same-origin page)
2. WebSocket connects unauthenticated (gets
PublicApi)
3. Client calls authenticate(token) to get the full API
4. Malicious sites can't get the token (CORS blocks page requests)$3
#### 1. Enable Sessions (Required)
The auth token is tied to sessions:
`typescript
// vite.config.ts
beamPlugin({
actions: "/app/actions/*.tsx",
session: true, // Uses env.SESSION_SECRET
});
`#### 2. Use authMiddleware
`typescript
// 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
`tsx
// app/routes/_renderer.tsx
import { jsxRenderer } from "hono/jsx-renderer";export default jsxRenderer((c, { children }) => {
const token = c.get("beamAuthToken");
return (
{children}
);
});
`Or use the helper:
`tsx
import { beamTokenMeta } from "@benqoder/beam";
import { Raw } from "hono/html";
;
`#### 4. Set Environment Variable
`bash
.dev.vars (local) or Cloudflare dashboard (production)
SESSION_SECRET=your-secret-key-at-least-32-chars
`$3
| 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) |$3
| 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 |$3
- Algorithm: HMAC-SHA256
- Lifetime: 5 minutes (configurable)
- Payload:
{ sid: sessionId, uid: userId, exp: timestamp }
- Format: base64(payload).base64(signature)$3
If you need to generate tokens outside the middleware:
`typescript
const token = await beam.generateAuthToken(ctx);
`---
Programmatic API
Call actions directly from JavaScript using
window.beam:`javascript
// 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'
},
);
`$3
`typescript
window.beam.actionName(data?, options?) → Promise// data: Record - 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 }
`$3
The API automatically handles:
- HTML swapping: If
options.target is 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`javascript
// 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");
`$3
`javascript
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 state
`---
Server Redirects
Actions can trigger client-side redirects using
ctx.redirect():`typescript
// app/actions/auth.tsx
export function logout(ctx: BeamContext) {
// Clear session, etc.
return ctx.redirect("/login");
}export function requireAuth(ctx: BeamContext) {
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
$3
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:`html
beam-action="getProducts"
beam-params='{"page": 2}'
beam-target="#product-list"
beam-replace="?page=2"
>
Page 2
`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.`tsx
// In your route handler
const page = parseInt(c.req.query("page") || "1");
const products = await getProducts(page);
`$3
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)
`tsx
{products.map((p) => (
))}
{hasMore && (
class="load-more-sentinel"
beam-infinite
beam-action="loadMoreInfinite"
beam-params='{"page": 2}'
beam-target="#product-list"
/>
)}
`#### Load More Button (click to trigger)
`tsx
{products.map((p) => (
))}
{hasMore && (
class="load-more-btn"
beam-load-more
beam-action="loadMoreProducts"
beam-params='{"page": 2}'
beam-target="#product-list"
>
Load More
)}
`Both patterns work the same way:
1. User loads more content (scroll or click)
2. User navigates away (clicks a product)
3. User clicks the back button
4. Content and scroll position are restored
5. Fresh server data is applied over cached items (using
beam-item-id)#### Keeping Items Fresh with
beam-item-idWhen 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:`tsx
...
...
...
`On back navigation:
1. Cache is restored (all loaded items + scroll position)
2. Fresh server items are applied over matching cached items
3. 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:1. Duplicates are replaced: If an item with the same
beam-item-id already exists, it's updated with fresh data
2. No double-insertion: The duplicate is removed from incoming HTML after updatingThis 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
`html
...
...
`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):
`tsx
// app/actions/loadMore.tsx
export async function loadMoreProducts(ctx, { page }) {
const items = await getItems(page);
const hasMore = await hasMoreItems(page); return (
<>
{items.map((item) => (
))}
{hasMore ? (
beam-load-more
beam-action="loadMoreProducts"
beam-params={JSON.stringify({ page: page + 1 })}
beam-target="#product-list"
>
Load More
) : (
No more items
)}
>
);
}
`#### Clearing Scroll State
To manually clear the saved scroll state:
`javascript
window.beam.clearScrollState();
``This is useful when you want to force a fresh load, such as after a filter change or form submission.
---
Beam requires modern browsers with WebSocket support:
- Chrome 16+
- Firefox 11+
- Safari 7+
- Edge 12+
---
MIT