Phoenix LiveView recently released a new feature called “hooks” that introduces Javascript interoperability into the LiveView lifecycle. Put simply, we can now run arbitrary Javascript every time a DOM node is changed by LiveView! LiveView hooks are a complete game changer, and open the doors to a whole new world of applications that can be built with this amazing technology.

As a proof of concept, let’s use LiveView hooks to animate an HTML5 canvas in real time using data provided by the server!

Getting Set Up

To keep this article short(er), we’ll skip the rigmarole of configuring your application to use LiveView. If you need help with this step, I highly recommend you check out Sophie DeBenedetto’s thorough walkthrough. Be sure to cross reference with the official documentation, as things are moving quickly in the LiveView world.

Moving forward, let’s assume that you have a bare-bones LiveView component attached to a route that looks something like this:


defmodule LiveCanvasWeb.PageLive do
  use Phoenix.LiveView
  
  def render(assigns) do
    ~L"""
    <canvas>
      Canvas is not supported!
    </canvas>
    """
  end
  
  def mount(_session, socket) do
    {:ok, socket}
  end
end

We’ll also assume that your assets/js/app.js file is creating a LiveView connection:


import LiveSocket from "phoenix_live_view";

let liveSocket = new LiveSocket("/live");

liveSocket.connect();

Now that we’re on the same page, let’s get started!

Generating Data to Animate

Before we start animating on the client, we should have some data to animate. We’ll start by storing a numeric value called i in our LiveView process’ assigns:


def mount(_session, socket) do
  {:ok, assign(socket, :i, 0)}
end

Next, we’ll increase i by instructing our LiveView process to send an :update message to itself after a delay of 16 milliseconds:


def mount(_session, socket) do
  Process.send_after(self(), :update, 16)
  {:ok, assign(socket, :i, 0)}
end

When we handle the :udpate message in our process, we’ll schedule another recursive call to :update and increment the value of i in our socket’s assigns:


def handle_info(:update, %{assigns: %{i: i}} = socket) do
  Process.send_after(self(), :update, 16)
  {:noreply, assign(socket, :i, i + 0.05)}
end

Our LiveView process now has an i value that’s slowly increasing by 0.05 approximately sixty times per second.

Now that we have some data to animate, let’s add a canvas to our LiveView’s template to hold our animation:


def render(assigns) do
  ~L"""
  <canvas data-i="<%= @i %>">
    Canvas is not supported!
  </canvas>
  """
end

Notice that we’re associating the value of i with our canvas by assigning it to a data attribute on the DOM element. Every time i changes in our process’ state, LiveView will update our canvas and set the value of data-i to the new value of i.

This is great, but to render an animation in our canvas, we need some way of executing client-side Javascript every time our canvas updates. Thankfully, LiveView’s new hook functionality lets us do exactly that!

Hooking Into LiveView

LiveView hooks lets us execute Javascript at various points in a DOM node’s lifecycle, such as when the node is first mounted, when it’s updated by LiveView, when it’s destroyed and removed from the DOM, and when it becomes disconnected or reconnected to our Phoenix server.

To hook into LiveView’s client-side lifecycle, we need to create a set of hooks and pass them into our LiveSocket constructor. Let’s create a hook that initializes our canvas’ rendering context when the element mounts, and renders a static circle every time the element updates:


let hooks = {
  canvas: {
    mounted() {
      let canvas = this.el;
      let context = canvas.getContext("2d");
      
      Object.assign(this, { canvas, context });
    },
    updated() {
      let { canvas, context } = this;
      
      let halfHeight = canvas.height / 2;
      let halfWidth = canvas.width / 2;
      let smallerHalf = Math.min(halfHeight, halfWidth);
      
      context.clearRect(0, 0, canvas.width, canvas.height);
      context.fillStyle = "rgba(128, 0, 255, 1)";
      context.beginPath();
      context.arc(
        halfWidth,
        halfHeight,
        smallerHalf / 16,
        0,
        2 * Math.PI
      );
      context.fill();
    }
  }
};

let liveSocket = new LiveSocket("/live", { hooks });

liveSocket.connect();

Notice that we’re storing a reference to our canvas and our newly created rendering context on this. When LiveView calls our lifecycle callbacks, this points to an instance of a ViewHook class. A ViewHook instance holds references to our provided lifecycle methods, a reference to the current DOM node in el, and various other pieces of data related to the current set of hooks. As long as we’re careful and we don’t overwrite these fields, we’re safe to store our own data in this.

Next, we need to instruct LiveView to attach this new set of canvas hooks to our canvas DOM element. We can do that with the phx-hook attribute:


<canvas
  data-i="<%= @i %>"
  phx-hook="canvas"
>
  Canvas is not supported!
</canvas>

When our page reloads, we should see our circle rendered gloriously in the center of our canvas.

Resizing the Canvas

On some displays, our glorious circle may appear to be fuzzy or distorted. This can be fixed by scaling our canvas to match the pixel density of our display. While we’re at it, we might want to resize our canvas to fill the entire available window space.

We can accomplish both of these in our mounted callback:


mounted() {
  let canvas = this.el;
  let context = canvas.getContext("2d");
  let ratio = getPixelRatio(context);
  
  resize(canvas, ratio);
  
  Object.assign(this, { canvas, context });
}

Where getPixelRatio is a helper function that determines the ratio of physical pixels in the current device’s screen to “CSS pixels” which are used within the rendering context of our canvas:


const getPixelRatio = context => {
  var backingStore =
    context.backingStorePixelRatio ||
    context.webkitBackingStorePixelRatio ||
    context.mozBackingStorePixelRatio ||
    context.msBackingStorePixelRatio ||
    context.oBackingStorePixelRatio ||
    context.backingStorePixelRatio ||
    1;
  
  return (window.devicePixelRatio || 1) / backingStore;
};

And resize is a helper function that modifies the canvas’ width and height attributes in order to resize our canvas to fit the current window, while fixing any pixel density issues we may be experiencing:


const resize = (canvas, ratio) => {
  canvas.width = window.innerWidth * ratio;
  canvas.height = window.innerHeight * ratio;
  canvas.style.width = `${window.innerWidth}px`;
  canvas.style.height = `${window.innerHeight}px`;
};

Unfortunately, our canvas doesn’t seem to be able to hold onto these changes. Subsequent calls to our updated callback seem to lose our resize changes, and the canvas reverts back to its original, blurry self. This is because when LiveView updates our canvas DOM node, it resets our width and height attributes. Not only does this revert our pixel density fix, it also forcefully clears the canvas’ rendering context.

LiveView has a quick fix for getting around this problem. By setting phx-update to "ignore" on our canvas element, we can instruct LiveView to leave our canvas element alone after its initial mount.


<canvas
  data-i="<%= @i %>"
  phx-hook="canvas" 
  phx-update="ignore"
>
  Canvas is not supported!
</canvas>

Now our circle should be rendered crisply in the center of our screen.

Animating Our Circle

We didn’t go all this way to render a static circle in our canvas. Let’s tie everything together and animate our circle based on the ever-changing values of i provided by the server!

The first thing we’ll need to do is update our updated callback to grab the current value of the data-i attribute:


let i = JSON.parse(canvas.dataset.i);

The value of canvas.dataset.i will reflect the contents of our data-i attribute. All data attributes are stored as strings, so a call to JSON.parse will convert a value of "0.05" to its numeric counterpart.

Next, we can update our rendering code to move our circle based on the value of i:


context.arc(
  halfWidth + (Math.cos(i) * smallerHalf) / 2,
  halfHeight + (Math.sin(i) * smallerHalf) / 2,
  smallerHalf / 16,
  0,
  2 * Math.PI
);

That’s it! With those two changes, our circle will rotate around the center of our canvas based entirely on real-time data provided by our server!

Requesting Animation Frames

Our solution works, but by forcing re-renders on the browser, we’re being bad net citizens. Our client may be forcing re-renders when its tab is out of focus, or it may be re-rendering more than sixty times per second, wasting CPU cycles.

Instead of telling the browser to re-render our canvas on every LiveView update, we should invert our control over rendering and request an animation frame from the browser on every update.

The process for this is straight forward. In our updated callback, we’ll wrap our rendering code in a lambda passed into requestAnimationFrame. We’ll save the resulting request reference to this.animationFrameRequest:


this.animationFrameRequest = requestAnimationFrame(() => {
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.beginPath();
  context.arc(
    halfWidth + (Math.cos(i) * smallerHalf) / 2,
    halfHeight + (Math.sin(i) * smallerHalf) / 2,
    smallerHalf / 16,
    0,
    2 * Math.PI
  );
  context.fill();
});

It’s conceivable that our LiveView component may update multiple times before our browser is ready to re-render our canvas. In those situations, we’ll need to cancel any previously requested animation frames, and re-request a new frame. We can do this by placing a guard just above our call to requestAnimationFrame:


if (this.animationFrameRequest) {
  cancelAnimationFrame(this.animationFrameRequest);
}

With those two changes, our LiveView hooks will now politely request animation frames from the browser, resulting in a smoother experience for everyone involved.

Taking it Further

Using a canvas to animate a numeric value updated in real-time by a LiveView process running on the server demonstrates the huge potential power of LiveView hooks, but it’s not much to look at.

We can take things further by generating and animating a much larger set of data on the server. Check out this example project that simulates over two hundred simple particles, and renders them on the client at approximately sixty frames per second:

Is it a good idea to take this approach if your goal is to animate a bunch of particles on the client? Probably not. Is it amazing that LiveView gives us the tools to do this? Absolutely, yes! Be sure to check out the entire source for this example on Github!

Hooks have opened the doors to a world of new possibilities for LiveView-based applications. I hope this demonstration has given you a taste of those possibilities, and I hope you’re as eager as I am to explore what we can do with LiveView moving forward.

Update: 9/30/2019

The technique of using both phx-hook and phx-update="ignore" on a single component no longer works as of phoenix_live_view version 0.2.0. The "ignore" update rule causes our hook’s updated callback to not be called with updates.

Joxy pointed this issue out to me, and helped me come up with a workaround. The solution we landed on is to wrap our canvas component in another DOM element, like a div. We leave our phx-update="ignore" on our canvas to preserve our computed width and height attributes, but move our phx-hook and data attributes to the wrapping div:


<div
  phx-hook="canvas"
  data-particles="<%= Jason.encode!(@particles) %>"
>
  <canvas phx-update="ignore">
    Canvas is not supported!
  </canvas>
</div>

In the mounted callback of our canvas hook, we need to look to the first child of our div to find our canvas element:


mounted() {
  let canvas = this.el.firstElementChild;
  ...
}

Finally, we need to pass a reference to a Phoenix Socket directly into our LiveSocket constructor to be compatible with our new version of phoenix_live_view:


import { Socket } from "phoenix";
let liveSocket = new LiveSocket("/live", Socket, { hooks });

And that’s all there is to it! Our LiveView-powered confetti generator is back up and running with the addition of a small layer of markup. For more information on this update, be sure to check out this issue I filed to try to get clarity on the situation. And I’d like to give a huge thanks to Joxy for doing all the hard work in putting this fix together!