Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Custom Kinos with Elixir and JavaScript

custom_kinos_with_elixir_and_javascript.livemd

Custom Kinos with Elixir and JavaScript

Mix.install([
  {:kino, "~> 0.11.0"}
])

Introduction

Livebook allows developers to implement their own kinos. This allows developers to bring their own ideas to life and extend Livebook in unexpected ways.

There are two types of custom kinos: static, via Kino.JS, and dynamic, via Kino.JS.Live. We will learn to implement both in this notebook.

HTML rendering with Kino.JS

The “hello world” of custom kinos is one that embeds and renders a given HTML string directly on the page.

We can implement it using Kino.JS in less than 15 LOC:

defmodule KinoGuide.HTML do
  use Kino.JS

  def new(html) when is_binary(html) do
    Kino.JS.new(__MODULE__, html)
  end

  asset "main.js" do
    """
    export function init(ctx, html) {
      ctx.root.innerHTML = html;
    }
    """
  end
end

Let’s break it down.

To define a custom kino we need to create a new module. In this case we go with KinoGuide.HTML.

We start by adding use Kino.JS, which makes our module asset-aware. In particular, it allows us to use the asset/2 macro to define arbitrary files directly in the module source.

All custom kinos require a main.js file that defines a JavaScript module and becomes the entrypoint on the client side. The JavaScript module is expected to export the init(ctx, data) function, where ctx is a special object and data is the data passed from the Elixir side. In our example the init function accesses the root element with ctx.root and overrides its content with the given HTML string.

Finally, we define the new(html) function that builds our kino with the given HTML. Underneath we call Kino.JS.new/2 specifying our module and the data available in the JavaScript init function later. Again, it’s a convention for each kino module to define a new function to provide uniform experience for the end user.

Let’s give our Kino a try:

KinoGuide.HTML.new("""

Look!

I wrote this HTML from Kino!

"""
)

It works!

To learn more about other features provided by Kino.JS, including persisting the output of your custom kinos to .livemd files, check out the documentation.

Bidirectional live counter

Kinos with static data are useful, but they offer just a small peek into what can be achieved with custom kinos. This time we will try out something more exciting. Let’s use Kino.JS.Live to build a counter that can be incremented both through Elixir calls and client interactions. Not only that, our counter will automatically synchronize across pages as multiple users access our notebook.

Our custom kino must use both Kino.JS (for the assets) and Kino.JS.Live. Kino.JS.Live works under the client-server paradigm, where the client is the JavaScript code, and the server is your Elixir code. Your Elixir code has to define a series of callbacks (similar to a GenServer and other Elixir behaviours). In particular, we need to define:

  • A init/2 callback, that receives the argument and the “server” ctx (in contrast to the ctx in JavaScript, which is the client context)

  • A handle_connect/1 callback, which is invoked whenever a new client connects. In here you must return the initial state of the new client

  • A handle_event/3 callback, responsible for handling any messages sent by the client

  • A handle_cast/2 callback, responsible for handling any Elixir messages sent via Kino.JS.Live.cast/2

Here is how the code will look like:

defmodule KinoGuide.Counter do
  use Kino.JS
  use Kino.JS.Live

  def new(count) do
    Kino.JS.Live.new(__MODULE__, count)
  end

  def bump(kino) do
    Kino.JS.Live.cast(kino, :bump)
  end

  @impl true
  def init(count, ctx) do
    {:ok, assign(ctx, count: count)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok, ctx.assigns.count, ctx}
  end

  @impl true
  def handle_cast(:bump, ctx) do
    {:noreply, bump_count(ctx)}
  end

  @impl true
  def handle_event("bump", _, ctx) do
    {:noreply, bump_count(ctx)}
  end

  defp bump_count(ctx) do
    ctx = update(ctx, :count, &(&1 + 1))
    broadcast_event(ctx, "update", ctx.assigns.count)
    ctx
  end

  asset "main.js" do
    """
    export function init(ctx, count) {
      ctx.root.innerHTML = `
        
        Bump
      `;

      const countEl = document.getElementById("count");
      const bumpEl = document.getElementById("bump");

      countEl.innerHTML = count;

      ctx.handleEvent("update", (count) => {
        countEl.innerHTML = count;
      });

      bumpEl.addEventListener("click", (event) => {
        ctx.pushEvent("bump");
      });
    }
    """
  end
end

On the client side, we wrote some JavaScript that listens to button clicks and dispatches them to the server via pushEvent.

Let’s render our counter!

counter = KinoGuide.Counter.new(0)

As an experiment you can open another browser tab to verify that the counter is synchronized.

In addition to client events we can also use the Elixir API we defined for our counter.

KinoGuide.Counter.bump(counter)

In the next notebook we will get back to those concepts and extend Livebook with custom Smart cells!