DEV Community

NDREAN
NDREAN

Posted on • Updated on

Notes on LiveView Components and JS interactions

Phoenix LiveView provides a performant SSR rendering: you modify the assigns and the component renders and updates the dynamic part. This also makes "real time" easy: a pubsub system on every state mutation will trigger a render, in other words, "just" broadcast the assigns.
The efficiency can be easily observed with the browser's dev tools. Phoenix also brings convenient interaction with Javascript.
The source of these notes is the documentation. We look at various methods provided by Phoenix to display UI components. It explains some of the data flow between a UI component and the server. As an illustration, we show how easy is SSR data prefetch with LiveView.

Define a LiveView

A LiveView (LV) is a module that use Phoenix.LiveView. It is a supervised process and communicates with the browser via websockets. The state is located in this object, and we have access to it on both ends, client and server. The module uses a minimum of two callbacks, mount and render. The page can be served from the router with: live "/home", HomeLive.

We will display several buttons that increment a counter. Each button will increment the counter by a different power of 10, so a counter is a digit, almost.
We use different solutions to code them: Phoenix components, Live Component and hooks and React.

all buttons

use Phoenix.LiveView

def render(assigns) do
    ~H"""
    <button id="b1"  phx-click="inc1" phx-value-inc1={1} type="button">Increment: +1</button>
    <hr/>
    <SimpleCounter.display inc2={10} />
    <hr/>
    <.live_component module={LiveButton} id="b3" inc3={100} int={0}/>
    <hr/>
    <HookButton.display inc4={1000}/>
    <hr/>
    <ReactButtons.display inc5={10_000} inc6={100_000}/>
    <hr/>
    <HoverComp.display inc7={1_000_000}/>
    <p>Counter: <%= @count %></p>
     <h3><%= Jason.encode!(@clicks) %></h3>
    <h3><%= if @display_data != nil, do: Jason.encode!(elem(@display_data,1)) %></h3>
    """
  end
Enter fullscreen mode Exit fullscreen mode

Render a simple component from a LiveView

We display a button that increments a counter. This value is kept in the state, the LiveView socket.
We pass the increment value with the binding phx-value-. The coupling between the UI component button and the backend is done via a DOM binding phx-click="evt_name" that will call the function handle_event whose signature is (event_name, params, socket). The phx-value is sent to event handler callback params.

defmodule MyAppWeb.HomeLive do
  use Phoenix.LiveView

  def mount(_, _, socket) do
    {:ok, assign(socket, count: 0)}
  end

  def render(assigns) do
    ~H"""
      <button id="b1"  phx-click="inc1" phx-value-inc1={1} type="button">
        Increment: +1
      </button>
      <p>Counter: <%= @count %></p>
    """
  end

  def handle_event("inc1", %{"inc1" => inc1, socket) do
    {:noreply, socket |> update(:count, & &1 +String.to_integer(inc1)}
  end
end
Enter fullscreen mode Exit fullscreen mode

Refactor with a Function Component

The main idea is to encapsulate the "H" template we want to render into a reusable function and tidy the code. We refactor this a function component (FC) from Phoenix.Component. It takes assigns and renders an "H" template.
We named the function "display". It is called from the parent LV "in a React way": <SimpleCounter.display />

We change the increment value from 1 to 10. In the LV, we pass a prop inc2={10} to the FC in the same way a we do with React.

defmodule MyAppWeb.HomeLive do
  use Phoenix.LiveView

  def render(assigns) do
    ~H"""
      <SimpleCounter.display inc2={10}/>
      <p>Counter: <%= @count %></p>
    """
  end
Enter fullscreen mode Exit fullscreen mode

This "prop" will be available in the "assigns" of the FC. We can send the info inc2={10} to the LV event handler through the binding phx-value-inc2={10}:

defmodule SimpleCounter do
  use Phoenix.Component

  def display(%{inc2: inc2} = assigns) do
    ~H"""
      <button id="b2"  phx-click="inc2" 
      phx-value-inc2={inc2} type="button" >
        Func Comp: +<%= inc2 %>
      </button>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

The event handler in the LV receives it in the params:

  def handle_event("inc2", %{"inc2" => inc2}, socket) do
    inc2 = String.to_integer(inc2)
    {:noreply, socket |> update(:count, & &1 + inc2)}
  end
end
Enter fullscreen mode Exit fullscreen mode

Refactor with a Live Component

We want to keep track of the number of clicks on each button. Since we have several buttons thus several handlers, the LV state can hold it. However, we should rather use a GenServer to remove this load from the LiveView. We can imagine mounting a map clicks: %{b1: 0, b2: 0,...} in the socket and update it in every callback. For example, in the "b2" callback, we can write:

update(socket, :clicks, &Map.put(&1, :b2, &1.b2 + 1)
Enter fullscreen mode Exit fullscreen mode

We may alternatively want our component to have its own state. We can use a LiveComponent (LC) with Phoenix.LiveComponent.
The documentation recommends not using these components for this kind of DOM functionality, but this is a showcase.

"As a guideline, a good LiveComponent encapsulates application concerns and not DOM functionality [...] do not write a component that is simply encapsulating generic DOM components."

We define a LC module "LiveCounter" with a render function, and use it in the parent LV as below ("module" and "id" are mandatory):

defmodule MyAppWeb.HomeLive do
  use Phoenix.LiveView

  def render(assigns) do
    ~H"""
      <.live_component module={LiveButton} inc3={100} int={0} id="b3" />
      <p>Counter: <%= @count %></p>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

The LC wants a render function. Since we used a DOM id, we need to repeat it in the render. The button will trigger an "inc3 event. The callback to the "inc3" event can be placed in this LC. For this, we need to add the binding phx-target={@myself} in the render. This works only to LC, not FC.

defmodule LiveButton do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <button phx-click="inc3" phx-target={@myself} 
    phx-value-inc3={@inc3} id="b3" type="button" >
      Live Button +<%= @inc3%>, clicked: <%= @int %> 
   </button>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

When we examine with :erlang.pid_to_list(self()) and the sockets, we find the the LV and the LC are within the same process, but each hold a different state. The LC component sockets assigns are those passed in the LV .live_component function. This "local" handler can update its own state (internal counter here) and will send the info to the LV.

def handle_event("inc3", %{"inc3" => inc3}, socket) do
  socket = update(socket, :int, &(&1 + 1))  
  send(self(), %{inc3: inc3})
  {:noreply, socket}
end
Enter fullscreen mode Exit fullscreen mode

The callback in the parent LV is:

def handle_info(%{inc3: inc3}, socket) do
    inc3 = String.to_integer(inc3)

    socket =
      socket
      |> update(:count, &(&1 + inc3))
      |> update(:clicks, &Map.put(&1, :b3, &1.b3 + 1))
    {:noreply, socket}
  end
Enter fullscreen mode Exit fullscreen mode

The documentation proposes alternatively to broadcast the message with Phoenix.PubSub; this distributes the updates.

If we use a "hook" in the child live component which emits a "push", then use pushEventTo and reference the ID of the child live component if you want the child to handle the callback handle_event). Otherwise, a pushEvent will send the event only to the parent liveview.

From Hooks to React

First hook

Since LiveView manages the DOM, we must stick to LiveView's so-called "hooks". That guarantees that our interactions persist in the DOM . In our case, we add a listener on a click event to a node.

This demonstrates how to use hooks, even if they might not be the most appropriate for our purpose.

We call a new functional component "HookButton":

defmodule MyAppWeb.HomeLive do
  use Phoenix.LiveView
  def render(assigns) do
    ~H"""
      <HookButton.display inc4={1000}/>
      <p>Counter: <%= @count %></p>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

We define it and use the binding phx-hook (and add a dataset to pass info to Javascript):

defmodule HookButton do
  use Phoenix.Component

  def display(assigns) do
    ~H"""
      <button id="b4"  phx-hook="ButtonHook" data-inc4={@inc4} type="button">
        + <%= @inc4 %>
      </button>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

We define in Javascript an object with a mounted() function; it holds a listener on the event "click" from the button. We then use the pushEvent to populate the socket with the increment (hardcoded as a dataset):

// buttonHook.js
export const ButtonHook = {
  mounted() { 
    const button = document.getElementById('b4');
    if (!button) return;

    const inc4 = Number(button.dataset.inc4);
    button.addEventListener('click', () => {
      this.pushEvent('inc4', { inc4 });
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

We add this hook to the liveSocket object, but client side.

//app.js
import { ButtonHook} from ...
let liveSocket = new LiveSocket('/live', Socket, {
  params: { _csrf_token: csrfToken },
  hooks: {ButtonHook}
}
Enter fullscreen mode Exit fullscreen mode

It remains to add the handler server side. It is unchanged, up to the indexes.

Use React - statelful component

We firstly want the React component to maintain some state; in this case, it will be the number of clicks on the button. We see immediately one downside: we can't broadcast this data.

config: run npm i react react-dom in the "/assets" and adjust the settings for esbuild to use jsx where you need (only --format=esm for code splitting).

We firstly set a div for React to render. The increment value is in a data set and and we use a hook to call the React component:

defmodule ReactButtons do
  use Phoenix.Component

  def display(assigns) do
    ~H"""
    <div id="b5"  phx-hook="ReactHook" data-inc5={@inc5} data-inc6={@inc6} phx-update="ignore"></div>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode
# HomeLive
use Phoenix.LiveView
def render(assigns) do
  ~H"""
     <ReactButtons inc5={10_000} inc6={100_000}/>
   """ 
end
Enter fullscreen mode Exit fullscreen mode

In this example, we use a stateful React component to hold an internal state: the number of clicks. The component receives two props from the calling hook: the increment value and a function triggered onClick to send this value to the server. The server will then update the counter accordingly.

// Counter.jsx
import React from 'react';
export const Counters = ({ push, inc }) => {
  const [value, setValue] = React.useState(0);

  const action = () => {
    setValue(value => value + 1);
    push(inc);
  };
  return (
      <button onClick={action}>React Stateful +{inc}, clicked: {value}</button>
  );
};
Enter fullscreen mode Exit fullscreen mode

The hook is:

import React from 'react';
import { createRoot } from 'react-dom/client';

export const ReactHook = {
  mounted() {
    const container = document.getElementById('b5');
    if (!container) return;

    const inc5 = Number(container.dataset.inc5);

    import('./Counters.jsx').then(({Counters})=>
      createRoot(container).render(
        <Counter push={c => this.push(c)} inc={inc5} />
      )
    );
  },
  push(inc5) {
    this.pushEvent('inc5', { inc5 });
  },
};
Enter fullscreen mode Exit fullscreen mode

and we add this hook to the liveSocketas:

hooks: { ReactHook, ButtonHook },
Enter fullscreen mode Exit fullscreen mode

Server-side, the handler is still the same, indexed with 5.

Stateless React component

We want instead the state and "heavy" computing - the number of clicks on the button! - kept and made server side. The React component will only render when his props are changed.
We have several ways to render from the server. Each click will send an event to the server so that he can start his job and respond to the client:

  • ping-pong: the client sends a pushEvent and the server counter-part handle_event responds with a {:noreply, push_event(socket, "event2", data)} so the client handles it back with a handleEvent("event2", data
  • we can also respond within the server callback with a {:reply, data, socket} and have a callback within the client pushEvent. The latter is a bit shorter and we use it here.

Since we want the React component to react to an external change, using a state manager makes this easy. We will use Valtio here by example. For this library, change the esbuild config to --target=es2020 instead to remove some warnings (for example, the Zustand library is ok).

We modify the hook to pass a function as a prop. It sends an event to the server with a callback that expects some data. The returned data is used to mutate a store, proxied by the Valtio package.

import { proxy } from 'valtio';

export const store = proxy({
  countSSR: 0,
});

export const ReactHook = {
  mounted() {
    const container = document.getElementById('b5');
    if (!container) return;

    const inc6 = Number(container.dataset.inc6);

    import('./Counters.jsx').then(({Counters})=>
      createRoot(container).render(
        <Counters ssr={(c) => this.ssr(c)} incSSR={inc6} />
      )
    );
  },
  // with callback
  ssr(inc6) {
    this.pushEvent('ssr', {inc6}, ({ newCount }) => {
      store.countSSR = newCount; // <- state mutation
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

The React component is reactive using useSnapshot from Valtio:

import React from 'react';
import { useSnapshot } from 'valtio';
import { store } from './hookReact';

export const Counters = ({ ssr, incSSR }) => {
  const { countSSR } = useSnapshot(store);
  return (
      <button onClick={()=> ssr(incSSR)}>Stateless React: +{incSSR}, Clicked: {countSSR}</button>
  );
};
Enter fullscreen mode Exit fullscreen mode

The handler server side is:

def handle_event("ssr", %{"inc6" => inc6}, socket) do
  socket =
      socket
      |> update(:count6, &(&1 + 1))
      |> update(:count, &(&1 + inc6))
      |> update(:clicks, &Map.put(&1, :b6, &1.b6 + 1))
  {:reply, %{newCount: socket.assigns.count6}, socket}
end
Enter fullscreen mode Exit fullscreen mode

An example: Pre-fetch with notifications and push event from Phoenix.LiveView.JS

A tiny illustration of the power of SSR with the help of the JS interop of LiveView. We will pre-fetch data from an api when the user moves the mouse over a DOM element and cache it with ETS. The data is rendered from cache only when this element is clicked. We also update the counter and notify the user. This simulates a cached "pre-fetch": by doing this server side, there is no additional cost to the client (in terms of bandwidth usage) and this accelerates the rendering. All this is done is a simple way thanks to hooks when compared to a pure front-end solution.

We define the hook triggered on hovering:

export const Hover = {
  mounted() {
    const button = document.getElementById('b10')
    if (!button) return;
    button.addEventListener('mouseenter', e => {
      e.target.style.cursor = 'progress';
      this.pushEvent('prefetch', {});
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

We also define the notification hook (it isn't started on mount). It is async thus non-blocking - buttons are still active - just waiting for the user to validate or not the usage of notifications.

note that flash works only when navigating, thus notifications.

function sendNotification(msg, user) {
  const notification = new Notification('New message:', {
    icon: 'https://cdn-icons-png.flaticon.com/512/733/733585.png',
    body: `@${user}: ${msg}`,
  });
  setTimeout(() => {
    notification.close();
  }, 2_000);
}

const showError = () => window.alert('Notifications are blocked');

export const Notify = {
  mounted() {
    // don't ask user for notification permission on mount but only if one pushes this first notification
    this.handleEvent('notif', ({ msg }) => {
      if (!('Notification' in window)) return;

      (async () => {
        await Notification.requestPermission(permission => {
          return permission === 'granted'
            ? sendNotification(msg, 'you')
            : showError();
        });
      })();
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

Our "hooks" object is now:
hooks: { ReactHook, ButtonHook, Hover, Notify }.

The function component has two bindings: phx-hook to run the "prefetch", and phx-click using JS.push: this pushes the click event to the server to update the counter, send a notification with a push_event in the server callback, and display the fetched data (the hovered component is not a button but could be for example a card to whom you may want to ship extra data).

defmodule HoverComp do
  use Phoenix.Component
  alias Phoenix.LiveView.JS

  def display(assigns) do
    ~H"""
    <span id="b10" phx-hook="Hover" phx-click={JS.push("inc7", value: %{inc7: @inc7})}
    > Add +<%= @inc7 %> </span>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

It is used as <HoverComp inc={1_000_000}/>

We define the "click" handler:

 def handle_event("inc7", %{"inc7" => inc7}, socket) do
    socket =
      socket
      |> update(:count, &(&1 + inc7))
      |> update(:clicks, &Map.put(&1, :b7, &1.b7 + 1))
      |> assign(display_data: socket.assigns.data)

    {:noreply, push_event(socket, "notif", %{msg: "data here!"})}
end
Enter fullscreen mode Exit fullscreen mode

and the handler to the "prefetch":

def handle_event("prefetch", _, socket) do
    case socket.assigns.prefetching do
      true ->
        case WaitForIt.wait(:ets.lookup(:counters, :data) != []) do
          {:ok, data} -> {:noreply, socket |> assign(:data, data)}
          {:timeout, _} -> {:noreply, socket}
        end

      false ->
        case :ets.lookup(:counters, :data) do
          [] ->
            Logger.info("prefetch")
            socket = update(socket, :prefetching, &(!&1))
            case LiveviewCounters.FetchData.async(1) do
              {:ok, data} ->
                socket = update(socket, :prefetching, &(!&1))
                # immediat call
                {:noreply, socket |> assign(:data,  {:data, data})}

              {:error, reason} ->
                Logger.debug(reason)
                {:noreply, socket}
            end

          [data] ->
            Logger.info("CACHED")
            {:noreply, socket |> assign(:data, data)}
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

and the "fetch":

defmodule LiveviewCounters.FetchData do
  @url "https://jsonplaceholder.typicode.com/todos/"

  defp get_page(i), do: @url <> to_string(i)

  def async(i) do
    task = Task.async(fn -> HTTPoison.get(get_page(i)) end)

    case Task.yield(task) do
      {:ok, {:ok, %HTTPoison.Response{status_code: 200, body: body}}} ->
        body = Poison.decode!(body)
        true = :ets.insert(:counters, {:data, body})
        {:ok, body}

      {:ok, {:error, %HTTPoison.Error{reason: reason}}} ->
        {:error, reason}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Note: an easy way to evaluate the performance is to enable the "paint flashing" within Chrome's dev tools and monitor the renderings.

rendering

Conclusion

Last step in to make this real time and write tests in LiveView. [Github].

Oldest comments (0)