p.enthalabs

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.

Video 3

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_down` and `$key_up`, with a rich event payload (`key`, `code`, modifier flags, `repeat`) and template-level key filters.

- **Scroll and resize** - `$scroll` reports the scroll offset of an element or the page. `$resize` reports an element's box sizes, or fires on a window resize.

- **Click-outside** - `$click_outside` fires when a click lands outside the bound element. Think dropdowns, popovers, and menus.

- **Scroll-edge (reach)** - `$reach_top`, `$reach_bottom`, `$reach_left`, and `$reach_right` fire 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, `ms` after 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>`

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

- **New `holo.compiler.page_to_mfa_paths` Mix 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 `:telemetry` events, 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 test` when 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 (like `luerl`), a client crash on routes that declare a `param`, 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

Sponsored by

![Image 1: Curiosum](https://www.curiosum.com/?utm_source=hologram.page&utm_medium=sponsor-logo&utm_campaign=sponsoring_hologram)

Main sponsor

![Image 2: Erlang Ecosystem Foundation](https://erlef.org/)

Milestone sponsor