At BetterDoc we’ve been using Phoenix LiveView for a little over a year now, and it’s gradually replacing big parts of our software. And if you’ve been using LiveView as well, chances are you’ve come to appreciate the programming model and all the thoughtful conveniences on offer- not least of which the client-side bindings. These bindings can act as an entry point for client to server communication or help sprinkle a bit of client-side reactivity, and all of this works without us actually having to write any ‘proper’ JavaScript.

It’s a bit hard to explain the attraction of not writing JavaScript in a way that everyone can relate to, but for me it boils down to this: it’s not about JavaScript per se, but rather about the code in general. Less code is less liability. If we don’t have to write the code ourselves, then we don’t have to maintain it or test it either. We can rely instead on the Phoenix team to put out these well-tested abstractions - of superb quality I might add - that we can leverage.

It will come as no surprise then that we can be very reluctant to go outside the LiveView rails put in place by the Phoenix team (we don’t want to void the guarantee after all!) but then again sometimes we have to.

When it comes to this particular problem (running complex client-side logic) LiveView offers the Hooks escape hatch: if we want some custom behaviour that isn’t baked in, then we can write the code ourselves, plug-it in using phx-hook="MyHook", and LiveView will make sure to call us back. While this sandbox escaping is appreciated (and we’ve used it too), it puts a bit of an extra burden on the application developers to maintain and test the code.

Problem definition

There is a special case in between these two options though, between sticking to vanilla LiveView bindings and rolling our own Hook with custom logic: what if what we need done is perfectly achievable using the JS commands, but there’s no binding in place to use?

That’s the problem we are trying to solve. Can we leverage the convenience and composability of the JS commands API without having to tap into the lesser-used, maybe a bit clunkier caller API of Hooks and without writing a big chunk of tests on top? Enter custom bindings.

LiveView bindings already cover a lot of browser events, e.g. phx-click for reacting to mouse clicks / finger taps or phx-keydown for reacting to keyboard buttons. But we needed support for mouseover events - and this is how we went about it.

We wanted a caller API that looked and felt very similar to that of the other bindings. If LiveView supported mouseover events, we’d probably be writing something like phx-mouseover={JS.toggle(to: "#my-element")}. Looking at the problem we realized we had to account for 3 things:

  1. we need to be able to identify new DOM nodes when LiveView (re) renders parts of the page and make sure we attach our code to those
  2. we also need to execute the JS commands somehow
  3. and finally, we need to make sure that if at any point in the future LiveView provides native support for mouseover events, our custom implementation will not be clashing with LiveView’s own until we get a chance to replace it

Let’s tackle each of these - but in reverse order:

Avoiding clashes with future LiveView versions

This one is easy. Instead of using phx-mouseover for the attribute name which has a high chance of being re-used by the LiveView team should they decide to support mouseover events, let’s use a different attribute name instead. Taking a cue from how custom HTTP headers are named, but also being aware of Alpine.js1 conventions (i.e. anything starting with x- is fair game for Alpine), we decided on xphx-mouseover (no dash between x and phx).

Executing LiveView JS Commands

This one proved to be straightforward too. When JS commands get serialized as attributes in the rendered html they end up as JSON instructions2 that are passed off to some execution engine to run.

For example this code in the .heex template

<span phx-click={JS.toggle(to:"my-element")}>foo</span>

gets turned into this rendered HTML

<span phx-click='[["toggle",{"display":null,"time":200,"ins":[[],[],[]],"to":"#my-element","outs":[[],[],[]]}]]'>foo</span>

A quick internet search reveals this answer on the Elixir forum: we can access this execution engine through the livesocket using liveSocket.execJS(dom_element, serialized_commands). Easy!

Reacting to DOM changes and making sure that new elements aren’t left out

Again, that one is not that hard to figure out, especially for old-timers familiar with the jQuery live method. We’re going to add an event listener on the window, and look for mouseover events that bubble up3 from all elements that are decorated with our custom attribute.

Here’s what the full solution looks like

// file: app.js

let liveSocket = new LiveSocket(...)


window.addEventListener("mouseover", e => {
  // if the event's target has the xphx-mouseover attribute,
  // execute the commands on that element
  if ("[xphx-mouseover]")) {



And now we can react on mouseover events with

<span xphx-mouseover={JS.toggle(to:"#my-element")}>foo</span>

which is exactly how we’re working with all the other client side bindings like e.g. phx-click.

With just 5 lines of glue JavaScript code that’s easy to verify for correctness, we get to re-use all JS commands that ship with LiveView. How cool is that?

Thanks to my colleague Lucas M. for providing thoughtful feedback on a first draft of this article.

  1. We’re not using Alpine.js at BetterDoc (LiveView at version 0.20 is plenty capable these days), but it used to be a fairly common component of LiveView apps in the beggining (remember PETAL?) 

  2. Couldn’t help but notice that the serialization chosen is data as code. Can’t get more LISP-y than that. 

  3. We’re in luck here because mouseover bubbles up. If we wanted to do something on mouseenter for example (which doesn’t bubble up), then the code would look a bit more complicated - we’d probably have to combine with phx:page-loading-stop and then attach to all elements of the page while making sure we don’t attach handlers more than once.