Hologram v0.10: Events, Middleware, and More

v0.9 was about realtime. v0.10 goes the other way: it deepens the foundation, so more of your app runs in the browser as pure Elixir. Mostly that means events. The client-side event system grew up this release, gaining keyboard, scroll, resize, click-outside, and scroll-edge events, bindings on the window and document, and a set of modifiers for shaping the event stream, controlling propagation, and overriding the browser's default. It all lives in your templates, gets checked at compile time, and never makes you write JavaScript. There's a new server-side middleware layer too, plus two more pieces of Elixir that now behave the same in the browser as on the server: comprehensions and error handling.

That's a full game of Space Invaders, running as pure Elixir in your browser. No JavaScript to write, no canvas library, no game engine, just the event system driving an SVG, in a tiny bundle. Every keypress runs through the same $key_down and $key_up bindings and action/3 handlers you'd write for a form (keydown to move and shoot, keyup to stop), and the whole input layer is one <window> tag:

<window
  $key_down.arrow_left="press_left"
  $key_down.arrow_right="press_right"
  $key_down.r="restart"
  $key_down.space.prevent_default="shoot"
  $key_up.arrow_left="release_left"
  $key_up.arrow_right="release_right"
/>

Play it here, then read on for how it works.

A First-Class Event System

Before v0.10, Hologram covered the basics: clicks, form changes, focus, a few pointer and transition events. That list is a lot longer now, with global bindings and a set of modifiers on top. The new event types alone cover most of what an interactive UI reaches for:

Key filters are where this gets good. You bind a handler to a specific key or combination right in the template, like $key_down.enter or $key_down.ctrl+k. Because Hologram compiles your templates, that filter is checked at compile time. Misspell a key as $key_down.entr and the build fails, pointing you at the closest valid name. In most frameworks the same typo is a silent no-op you only catch at runtime.

Window- and Document-Level Bindings

Some events don't belong to any rendered element: a global keyboard shortcut, a window resize, a page scroll. For those, the new <window> and <document> tags bind to the global window or document instead of an element. They render nothing and reuse the same $event syntax, key filters, and modifiers as any other binding. A listener stays live only while its tag is rendered, so putting one behind a conditional makes it listen only when that condition holds. The Space Invaders demo uses these to catch arrow keys across the whole page. A command-palette shortcut works the same way:

<window $key_down.ctrl+k="open_palette" />

Shaping the Event Stream

Some events fire many times a second: typing in a search box, moving the pointer, scrolling a list. Two modifiers tame that stream, and both attach right on the event name (and get validated at compile time):

<input $change.debounce(300)="search" />

There's also once, which fires a binding a single time and then stops, handy for a confirm button or lazy-loading the first time a container scrolls into view. The modifiers compose, too: $key_down.enter.debounce(300) debounces only the Enter key, and a throttled binding marked once fires its one throttled dispatch and then retires.

Controlling Propagation and the Native Default

DOM events bubble, and the browser has its own default behaviour for many of them. Three more modifiers let each binding say exactly what it wants:

<textarea $key_down.enter.prevent_default="send_message"></textarea>
⚠️ Heads up: behavior change

Hologram now calls event.preventDefault() on $submit only. A bound form still won't reload the page, but every other event is left alone. Before, Hologram suppressed the native default across many event types, which quietly broke things like text selection while a pointer binding was attached (#795, reported by @Blatts12). If you were leaning on that, add prevent_default to the bindings that need it.

One more refinement: a binding that resolves to nil is now disabled. Nothing dispatches, and the native behaviour is left untouched. Compute the operation from state and return nil to switch a binding off, which makes any binding conditional on the next re-render.

That's the tour. The full reference, every type, payload, filter, and modifier, lives in the Events documentation.

Middleware

The other headline feature is server-side middleware: reusable logic that runs before a page renders or a command executes. This is where cross-cutting concerns belong, like authentication, authorization, request enrichment (locale, theme, tenant, feature flags), rate limiting, and audit logging. A middleware is just a function over the Server struct, the same one init/3 and command/3 already receive, that returns an updated Server for the next middleware in line.

Reusable middleware is a module with a call/2. It stops the chain by producing a response (setting a status), so there's no separate halt. A redirect sets a status, so it's terminal by definition:

defmodule MyApp.Middleware.RequireAdmin do
  use Hologram.Middleware

  @impl Hologram.Middleware
  def call(server, _opts) do
    check_role(server, get_session(server, :role))
  end

  defp check_role(server, :admin), do: server
  defp check_role(server, _role), do: put_status(server, :forbidden)
end

Attach middleware to a page or component with the middleware macro. Point it at a module, or at an inline :function name for one-off logic. Declarations run top to bottom:

defmodule MyApp.SettingsPage do
  use Hologram.Page

  route "/admin/settings"

  middleware MyApp.Middleware.RequireAuth
  middleware MyApp.Middleware.RequireAdmin

  # ...
end

Composition is explicit, and works two ways: bundle several middleware modules into one composite, or share a stack across many pages or components by putting the declarations in a base module they use. Either way it resolves to one flat chain at compile time, with no runtime registry and nothing injected behind your back, so the whole chain stays visible where it's attached. Dispatch is flat on purpose, for safety: it runs only the target module's middleware. A component's commands are gated by the component's own middleware, never silently by an ancestor page.

Building this out also added the request-level fields people kept asking for on the Server struct: method, scheme, host, port, path, query, and IP (#781, reported by @adamtang79).

The full guide goes deeper, including the Server struct's zones, terminal responses, and the proxy trust model, over in the new Middleware documentation.

More of Elixir, Running in the Browser

Hologram's long game is rebuilding the Erlang and Elixir runtime in the browser, so your code runs there unchanged. v0.10 brings two more pieces up to parity with the server.

Comprehensions reach full parity on the client. Three changes close the gap: a correctness fix so later generators and filters see the bindings from earlier ones (#859), the :reduce option (#861), and bitstring generators (#863). A comprehension that folds a collection into a single value now runs in the browser exactly as it does on the server:

for entry <- log_entries, reduce: %{} do
  counts -> Map.update(counts, entry.level, 1, &(&1 + 1))
end

Error handling works on the client. The try special form runs in the browser now, with its rescue, catch, after, and else clauses, plus raise, reraise, throw, and the underlying :erlang.error/3, :erlang.exit/1, :erlang.raise/3, and :erlang.throw/1 ports (#901). So an action can catch a failure and recover from it locally, like turning a parse error into a message in state:

try do
  parse!(component.state.input)
rescue
  error in ArgumentError -> put_state(component, :error, error.message)
end

Tooling and Under the Hood

Maintenance Releases

Three patch releases shipped on the 0.9 line between v0.9 and now, and their fixes are all carried into v0.10:

Thanks to @0x130c, @absowoot, @adamtang79, @jamauro, @ken-kost, and @mikehostetler for reporting issues fixed in these releases.

Sponsors

I'd like to thank our sponsors whose support makes sustained development possible:

Thanks also to our GitHub sponsors:

And to every other GitHub sponsor: thank you! Contributions of any size genuinely help keep Hologram going.

If you'd like to support Hologram's development, consider sponsoring the project.

Stay in the Loop

Subscribe to the Hologram newsletter for a monthly roundup of everything Hologram: new releases and features, a glance at what's coming next, ecosystem news and new libraries, and the discussions worth catching from the community and socials, all in one place. You can also join us on Discord, the main hub for questions, discussion, and announcements, or find every way to connect on the community page.

- Bart

Sponsored by
Curiosum
Main sponsor
Erlang Ecosystem Foundation
Milestone sponsor