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:
- Keyboard -
$key_downand$key_up, with a rich event payload (key,code, modifier flags,repeat) and template-level key filters. - Scroll and resize -
$scrollreports the scroll offset of an element or the page.$resizereports an element's box sizes, or fires on a window resize. - Click-outside -
$click_outsidefires when a click lands outside the bound element. Think dropdowns, popovers, and menus. - Scroll-edge (reach) -
$reach_top,$reach_bottom,$reach_left, and$reach_rightfire as a scroll container's edge comes into view. The basis for infinite scroll, load-more, and pull-to-refresh.
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):
debounce(ms)- coalesces a burst into a single dispatch,msafter the events stop. Reach for it when you only care about the final state, like a search query after the user stops typing. Defaults to 250 ms.throttle(ms)- caps dispatches to at most one per interval while events keep firing. Reach for it when you want steady updates during an interaction, like a live cursor readout or a drag preview. Defaults to 100 ms.
<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:
stop_propagation- stops the event at the bound element, so ancestor bindings don't also fire (e.g. a delete button inside a clickable card).prevent_default- prevents the browser's native default for that binding (e.g. Enter-to-send in a textarea, without inserting a newline).allow_default- the mirror image: lets the native default through where Hologram would otherwise prevent it (e.g. a form that posts to an external endpoint you also want to track).
<textarea $key_down.enter.prevent_default="send_message"></textarea>
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
- New
holo.compiler.page_to_mfa_pathsMix task (#883) - exposes the compiler's reachability analysis from a page, useful for tooling and debugging what a page actually pulls in. - Telemetry in the compile task - the Hologram compiler now emits
:telemetryevents, so you can measure and track compilation in your own observability stack.
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:
- v0.9.1 - removed the Biome formatter from the compiler to fix exponentially slow compilation with deeply nested templates.
- v0.9.2 - fixed an SSE response not being halted, and a "no persistent term" error that broke
mix testwhen Hologram was disabled. - v0.9.3 - Elixir 1.19/1.20 and OTP 28/29 support, more Erlang functions ported to the browser (
:string.to_graphemes/1,:string.jaro_similarity/2,:lists.suffix/2), plus fixes for a compilation crash on Erlang dependencies that use Elixir-style module names (likeluerl), a client crash on routes that declare aparam, and a couple of runtime edge cases.
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:
- Main Sponsor: Curiosum - ongoing sponsorship along with business insight and adoption guidance, helping shape Hologram's roadmap based on real-world production needs
- Milestone Sponsor: Erlang Ecosystem Foundation - milestone-based stipend, helping fund key development goals
Thanks also to our GitHub sponsors:
- Innovation Partner: Sheharyar Naseer (@sheharyarn)
- Framework Visionaries: @absowoot, Oban (@oban-bg), Robert Urbańczyk (@robertu), Moss Piglet (@moss-piglet)
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