npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@benqoder/beam

v0.7.0

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
  • Per-Call Hono Middleware - Re-enter Hono for every RPC action without switching the browser to HTTP
  • 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
  • Beam Visits - Upgrade normal SSR links into Beam-powered visits
  • History Management - Push/replace browser history for actions and visits
  • 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)
  • Server State Updates - Update named client state from server actions without swapping DOM
  • Cloak - Hide elements until reactivity initializes (no flash of unprocessed content)
  • Lazy Connections - Beam connects only when needed by default
  • Multi-Render - Update multiple targets in a single action response
  • Async Components - Full support for HonoX async components in ctx.render()
  • Streaming Actions - Async generator handlers push incremental updates over WebSocket (skeleton → content, live progress, AI-style text)
  • requestContext Access - Read the live Hono request context from Beam actions when per-call middleware is enabled

Installation

npm install @benqoder/beam

Quick 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:

  • checkboxboolean (checked state)
  • number/rangenumber
  • 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 stable identity
  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:

  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 found

Notes:

  • beam-target accepts any valid CSS selector (e.g. #id, .class, [attr=value]). Using #id targets is still fully supported.
  • Auto-targeting (step 2) uses stable element identity from the returned HTML root: beam-id, beam-item-id, or id.
  • Auto-targeting intentionally does not use input name values, because they are often not unique enough for safe DOM replacement.
  • Prefer beam-id for named UI regions and beam-item-id for repeated/list items. Use plain id when that already matches your markup structure.
  • 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:

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.


Streaming Actions

Turn any action into a streaming action by making it an async generator (async function*). Each yield pushes an update to the browser immediately — no waiting for the full response.

export async function* loadProfile(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
  // First yield: show skeleton immediately
  yield ctx.render(<div id="profile">Loading…</div>)

  // Simulate slow API call
  await delay(1800)

  const user = await db.getUser(id as string)

  // Second yield: replace with real content
  yield ctx.render(
    <div id="profile">
      <h3>{user.name}</h3>
      <p>{user.role}</p>
    </div>
  )
}

Use it in HTML exactly like a regular action — no special attribute needed:

<button beam-action="loadProfile" beam-include="id">Load Profile</button>
<div id="profile"></div>

Patterns

Skeleton → Content — yield a skeleton immediately so the user sees feedback, then yield the real content once the data is ready:

export async function* loadProfile(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
  yield ctx.render(<SkeletonCard id="result" />)
  const user = await fetchUser(id)
  yield ctx.render(<UserCard id="result" user={user} />)
}

Multi-step Progress — yield after each step completes to show a live progress list:

export async function* runPipeline(ctx: BeamContext<Env>, _: Record<string, unknown>) {
  const steps = ['Validating', 'Fetching', 'Processing', 'Saving']
  const done: string[] = []

  for (const step of steps) {
    yield ctx.render(<Pipeline done={done} current={step} pending={steps.slice(done.length + 1)} id="pipeline" />)
    await runStep(step)
    done.push(step)
  }

  yield ctx.render(<Pipeline done={done} complete id="pipeline" />)
}

AI-style Text Streaming — accumulate text and re-render on each chunk for a typewriter effect:

export async function* streamText(ctx: BeamContext<Env>, _: Record<string, unknown>) {
  let text = ''
  for (const word of words) {
    text += (text ? ' ' : '') + word
    await delay(120)
    yield ctx.render(<p id="output">{text}<span class="cursor">▌</span></p>)
  }
  yield ctx.render(<p id="output">{text}</p>) // remove cursor
}

Streaming into Modals and Drawers — yield ctx.modal() or ctx.drawer() calls. The first yield opens the overlay; subsequent yields update its content:

export async function* openProfileModal(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
  yield ctx.modal(<SkeletonProfile />, { size: 'md' }) // opens modal with skeleton

  const user = await fetchUser(id)

  yield ctx.modal(<UserProfile user={user} />, { size: 'md' }) // swaps in real content
}
<button beam-modal="openProfileModal" beam-include="id">View Profile</button>

How It Works

  • A regular action handler returns a single value → one DOM update.
  • An async generator handler yields multiple values → one DOM update per yield, streamed over the WebSocket as each chunk is ready.
  • The client processes chunks in order; each chunk is a full ActionResponse (the same object a regular action returns).
  • No special HTML attributes are needed — the server detects async generators automatically.

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:

  • 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:

<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 |

Auto-Reconnect

| Element / Attribute | Description | | ---------------------------------------------------------------- | ------------------------------------------------- | | <meta name="beam-reconnect-interval" content="5000"> | Fixed retry interval (ms) after initial backoff | | beam-disconnected | Show element while disconnected, hide on reconnect | | <meta name="beam-auto-connect" content="true"> | Explicitly opt into eager Beam connection |

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 descendant same-origin links into Beam SSR visits | <main beam-boost> | | beam-boost-off | Exclude specific links from Beam visits | beam-boost-off | | beam-visit | Explicitly mark a link as a Beam visit | <a beam-visit href> | | beam-patch | Visit with patch-style scroll semantics | <a beam-patch href> | | beam-navigate | Visit with navigation-style scroll semantics | <a beam-navigate href> |

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-init | Run JS expression once after state is initialized | beam-init="setInterval(() => { index = (index+1) % total }, 3000)" | | beam-cloak | Hide element until its reactive scope is ready | <div beam-state="open: false" beam-cloak> | | 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 float
  • integer - Parse as integer
  • boolean - Convert "true"/"1"/"yes" to true
  • trim - 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.

Beam matches kept elements by stable identity in this order:

  • beam-id
  • beam-item-id
  • id
  • unique form control name as a best-effort fallback

For the most reliable behavior, give kept elements a beam-id, beam-item-id, or id. If multiple inputs share the same name, Beam will not preserve by name alone.

Use cases:

  • beam-id: shared UI regions like badges, panels, counters, and named state scopes
  • beam-item-id: repeated records in feeds, tables, carts, and paginated lists
  • id: one-off DOM anchors when the element already has a stable page-level ID

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 matching stable identity:

<!-- This badge updates when any action returns beam-id="cart-count" -->
<span beam-id="cart-count" beam-hungry>0</span>

<!-- Clicking this updates both #cart-result AND the hungry badge -->
<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 beam-id="cart-count">{cartCount}</span>
    </>
  );
}

Hungry matching checks identity in this order:

  • beam-id
  • beam-item-id
  • id

For shared UI regions, prefer beam-id. For repeated records, prefer beam-item-id.


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.


Auto-Reconnect

Beam automatically reconnects when the WebSocket drops (e.g. server restart, network blip). No configuration required — it works out of the box.

Reconnect Schedule

| Attempt | Delay | | ------- | ----- | | 1st | 1s | | 2nd | 3s | | 3rd | 5s | | 4th+ | 5s (configurable) |

After the first three stepped attempts, Beam retries indefinitely at a fixed interval until the connection is restored.

Configuring the Interval

Override the fixed interval with a <meta> tag in your <head>:

<!-- Retry every 10 seconds after the initial backoff steps -->
<meta name="beam-reconnect-interval" content="10000">

The value is in milliseconds. Values below 1000ms are ignored (minimum 1s).

Disconnect Indicator

Show UI while disconnected using beam-disconnected:

<div beam-disconnected style="display:none" class="banner">
  Reconnecting...
</div>

The element is shown automatically on disconnect and hidden on reconnect. The body also receives the .beam-disconnected class while offline.

Events

| Event | Fires when | | ---------------------- | ----------------------------------- | | beam:disconnected | WebSocket connection drops | | beam:reconnected | Connection successfully restored |

window.addEventListener('beam:reconnected', () => {
  console.log('Back online!')
})

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>

Beam Visits

Upgrade regular SSR links into Beam-powered visits while keeping normal navigation as the fallback:

<!-- Boost all eligible links in a container -->
<main beam-boost>
  <a href="/page1">Page 1</a>
  <!-- Uses Beam visit -->
  <a href="/page2">Page 2</a>
  <!-- Uses Beam visit -->
  <a href="https://external.com">External</a>
  <!-- Automatically left alone -->
  <a href="/download/report.pdf" beam-boost-off>Download</a>
  <!-- Explicitly opt out -->
</main>

<!-- Or opt in per link -->
<a href="/about" beam-visit beam-target="#content">About</a>
<a href="/products?page=2" beam-patch beam-target="#content">Next Page</a>
<a href="/login" beam-navigate beam-target="#content">Login</a>

Visited links:

  • render the real SSR route internally through Beam
  • update the specified target (default: the nearest [beam-boost] shell, otherwise body)
  • update browser history and page title
  • prefetch on hover/touch intent
  • fall back to normal hard navigation when Beam cannot safely apply the visit

Beam automatically bypasses visits for:

  • external links
  • download links
  • links with target="_blank"
  • modifier-click / middle-click navigation
  • links marked with beam-boost-off

Current scope:

  • links are fully supported
  • forms are intentionally not part of the visit lifecycle yet

Fallback behavior:

  • if the route returns a redirect, non-HTML response, asset mismatch, opt-out signal, or cookie-writing response, Beam falls back to normal browser navigation so session/auth behavior stays correct

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 (manual) -->
<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>

<!-- Auto-play carousel (beam-init starts the interval after state is ready) -->
<div
  beam-state="slide: 0; total: 5"
  beam-init="setInterval(() => { slide = (slide + 1) % total }, 3000)"
>
  <div class="overflow-hidden">
    <div
      class="flex transition-transform duration-500"
      beam-attr-style="'transform: translateX(-' + (slide * 100) + '%)'"
    >
      <!-- slides here, each min-w-full -->
    </div>
  </div>
  <button beam-click="slide = (slide - 1 + total) % total">← Prev</button>
  <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>

beam-init — Run Code on State Initialization

beam-init runs a JavaScript expression once after the state scope is fully initialized. Use it for setInterval (auto-play), setTimeout, or any setup that needs access to the reactive state.

<!-- Auto-play carousel -->
<div
  beam-state="slide: 0; total: 4"
  beam-init="setInterval(() => { slide = (slide + 1) % total }, 3000)"
>
  ...
</div>

<!-- Derived initial value -->
<div
  beam-state="count: 0; doubled: 0"
  beam-init="doubled = count * 2"
>
  <span beam-text="doubled"></span>
</div>

Scoping: beam-init must be on the same element as beam-state (or on an element inside a scope). The expression runs with the same reactive state context as all other directives in the scope.

Note: beam-init only works inside a beam-state scope — it has no effect on elements without a state ancestor.

beam-cloak — Prevent Flash of Unprocessed Content

beam-cloak hides an element until its reactive scope has fully initialized. Without it, elements with beam-show may briefly appear before Beam processes them.

Add it to the same element as beam-state. Beam removes the attribute automatically after setup.

<!-- Without beam-cloak: the "Dropdown" content may flash visible on load -->
<!-- With beam-cloak: hidden until reactivity is ready, then shown correctly -->
<div beam-state="open: false" beam-cloak>
  <button beam-state-toggle="open">Toggle</button>
  <div beam-show="open">Dropdown content</div>
</div>

Beam ships a CSS rule that does the hiding:

[beam-cloak] { display: none !important; }

This rule is included in @benqoder/beam/styles. If you are not importing the beam stylesheet, add it yourself.

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.

Server-Driven Named State Updates

Server actions can update existing named state directly, without returning HTML. This is useful when the UI already has a beam-state scope and you only want to change its data.

Use ctx.state(id, value) for a single state update:

export function refreshCart(ctx: BeamContext<Env>) {
  return ctx.state("cart", {
    count: 3,
    total: 29.99,
  });
}

Use ctx.state({ ... }) to update multiple named states in one response:

export function syncDashboard(ctx: BeamContext<Env>) {
  return ctx.state({
    cart: { count: 3, total: 29.99 },
    notifications: 7,
  });
}
<div beam-state="count: 0; total: 0" beam-id="cart">
  Cart: <span beam-text="count"></span> items
  Total: $<span beam-text="total.toFixed(2)"></span>
</div>

<div beam-state="0" beam-id="notifications">
  Notifications: <span beam-text="notifications"></span>
</div>

<button beam-action="refreshCart">Refresh Cart</button>
<button beam-action="syncDashboard">Sync Everything</button>

Behavior:

  • Updates target existing named states by beam-id
  • Object payloads shallow-merge into object state
  • Primitive payloads replace simple named values
  • Elements using beam-state-ref update automatically
  • Missing state IDs are ignored with a console warning

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;
});

// Apply a named state update programmatically
beam.updateState("cart", { count: 4, total: 39.99 });

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}' beam-cloak>
  <button type="button" beam-state-toggle="open">Menu</button>
  <div beam-show="open">Content</div>
</div>

beam-cloak is the Beam equivalent of x-cloak — it hides the element until reactivity has initialized, preventing a flash of visible content.

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' })
}

BeamContext

Beam actions receive a BeamContext with:

  • env - Worker/runtime bindings
  • user - resolved Beam user
  • request - original request snapshot used to seed Beam
  • session - Beam session adapter
  • requestContext? - live Hono Context for the current internal action request when per-call middleware is enabled
  • response helpers like render(), state(), script(), redirect(), modal(), and drawer()
export function myAction(ctx: BeamContext<Env>) {
  const traceId = ctx.requestContext?.get('rpcTraceId')
  return ctx.render(<div>Trace: {traceId ?? 'none'}</div>)
}

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

  1. Enable sessions in vite.config.ts:
beamPlugin({
  actions: "/app/actions/*.tsx",
  session: true, // Enable with defaults (cookie storage)
});
  1. Add SESSION_SECRET to wrangler.toml:
[vars]
SESSION_SECRET = "your-secret-key-change-in-production"
  1. 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} />
}
  1. 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({
  s