DEV Community

loading...

Live view: first tiny steps

revent profile image Roberts Guļāns ・6 min read

A few days ago phoenix live view went public. I was anticipating its release since first sneak peek at elixir conf. Despite being in such early development stage, it already have great introduction, documentation and examples. There are also already many other community blogs on it. A great resource on what community is building already with it is #liveview tag on Twitter.

Assumptions
Works on my machine
  • elixir: 1.8.1
  • phoenix: 1.4.1

We will create a world clock app, for user to select and see what time it is in what parts of the world.

Let's start by registering the live view directly in routes.

defmodule MyAppWeb.Router do
  ...

  scope "/", MyAppWeb do
    pipe_through :browser

    # Registering live view handler for specific URL
    live "/time", TimeLive
  end
end

One gotcha for registering live views in routes is to provide default layout. Otherwise, you might get a case when view HTML itself on the initial request will be returned, but with no surrounding HTML. Most importantly js includes that handles live view updates from frontend side will be missing.

defmodule MyAppWeb.Router do
  ...

  pipeline :browser do
    plug Phoenix.LiveView.Flash
    ...
    # Add default layout
    plug :put_layout, {MyAppWeb.LayoutView, :app}
  end

  ...
end

First tiny step, show UTC live time

defmodule MyAppWeb.TimeLive do
  @moduledoc """
  A module that renders and handles all interactions from ui
  """

  use Phoenix.LiveView

  @doc """
  Renders this simple html with ony one plain varaible output
  """
  def render(assigns) do
    ~L"<%= @time %>"
  end

  @doc """
  First initial view state setup
  - Registers tick every second
  - Delegates work to handle tick
  """
  def mount(_session, socket) do
    :timer.send_interval(1000, self(), :tick)

    {:ok, handle_tick(socket)}
  end

  @doc """
  Delegates work to handle tick
  """
  def handle_info(:tick, socket) do
    {:noreply, handle_tick(socket)}
  end

  @doc """
  Handles each tick.
  - Gets current time in a human readable format
  - saves it for view to use
  """
  defp handle_tick(socket) do
    time = NaiveDateTime.utc_now()
    assign(socket, :time, time)
  end
end

Workflow:

  • mount callback is called an initial state (current time) for the view is prepared;
  • render callback with previously prepared data is called and HTML rendered;
  • HTML is sent to the browser;
  • every second (each tick) handle_info callback with :tick message is called, which triggers view to recalculate current time;
  • live view detects state changes and triggers render callback again and new HTML is generated
  • HTML is sent to the browser (i assume there are some optimizations, like sending the only diff, or something like that. Will have to look into that).

Allow the user to choose timezone

I still don't quite get timezones in elixir. By default, elixir supports only Etc/UTC. Found out about tzdata. Setting it up is outside if this scope, but it was pretty easy.

defmodule MyAppWeb.TimeLive do
  ...

  @doc """
  Renders html via phoenix tempaltes
  """
  def render(assigns) do
    MyAppWeb.LiveView.render("time.html", assigns)
  end

  @doc """
  Now when mounting we assign all timezones (for the user to choose from dropdown)
  Assign changeset (using changeset, because will accept input from the user)
  """
  def mount(_session, socket) do
    :timer.send_interval(1000, self(), :tick)

    socket =
      socket
      |> assign(:timezones, Tzdata.zone_list())
      |> assign(:changeset, LocalTime.timezone_changeset(%{timezone: "Etc/UTC"}))
      |> handle_tick()

    {:ok, socket}
  end

  @doc """
  Accepting dropdown value change.
  Creates new changeset with newly selected timezone.
  """
  def handle_event("update_timezone", %{"local_time" => new_timezone}, socket) do
    socket =
      socket
      |> assign(:changeset, LocalTime.timezone_changeset(new_timezone))
      |> handle_tick()

    {:noreply, socket}
  end

  @doc """
  Now handling tick means to update time, not with `NaiveDateTime`, but for selected timezone. 
  """
  defp handle_tick(socket) do
    assign(socket, :changeset, LocalTime.update_time(socket.assigns.changeset))
  end
end

Extracted view template lib/my_app_web/templates/live/time.html.leex looks as follows:

<%= f = form_for @changeset, "#", [phx_change: "update_timezone"] %>
  <%= select f, :timezone, @timezones, promt: "Select timezone" %>
  <%= text_input f, :time, readonly: true %>
</form>

You probably noticed that there are multiple references to LocalTime module. for changesets to work properly I had to create an embedded schema. It will also be responsible for validating user input.

defmodule LocalTime do
  @moduledoc """
  Handling data integrty
  """
  use Ecto.Schema

  alias Ecto.Changeset

  embedded_schema do
    field :timezone, :string
    field :time, :string
  end

  @doc """
  Changeset that does heavy lifting of validating if user input is correct
  """
  def timezone_changeset(params \\ %{}) do
    %__MODULE__{}
    |> Changeset.cast(params, [:timezone])
    |> Changeset.validate_inclusion(:timezone, Tzdata.zone_list())
    |> Map.put(:action, :insert)
  end

  @doc """
  Updates time for the specified changeset.
  If a changeset is invalid, there is no point on updating time, as the user has specified the wrong timezone.
  If timezone is correct, create calculate current datetime for specified timezone 
  """
  def update_time(%Changeset{valid?: false} = changeset) do
    changeset
  end

  def update_time(%Changeset{changes: %{timezone: timezone}} = changeset) do
    {:ok, time} = DateTime.now(timezone)
    Changeset.force_change(changeset, :time, time)
  end
end

Conclusion

The live view seems great technology. There are still many things I need to grasp (probably more phoenix framework wise). Manipulating changesets seemed odd. LocalTime shouldn't update time via Changeset. Or maybe it should, and I'm just wanting to make things more complex than they should be. In either case, after heavily using vuejs for a year. This seems so similar, yet so different.

Same same, but different

I wonder, will live view stack also grow, like all those modern js frameworks. Router for when you want to go to different page, but reload only part of the page. How will live view template nesting pan out? Some shared state manager, like redux or vuex. Will there eventually be a division of live view components in smart and stupid ones.

With all modern js craze, it seems that headless cmss, and crmss are the trend. The live view seems like a sane alternative when you don't want to maintain two stacks (backend, frontend) and make everything communicate via a json API. There is a place for that, don't get me wrong, but I already have seen many places, where it was introduced because for trendiness. And all complexity overhead that comes along with it was unnecessary.

For me, live view have this sweet spot, where I can see how I can do everything I was able to do with jquery (data related dom manipulation wise, don't know about animations jet), 80% of what I was able to do with vuejs, but with minimal new technology stack overhead (It will come down to component reusability, it such thing even is in roadmap).

What are your thoughts on it? Will or maybe already have used it? Did you like it? Do you see the place for it, in your toolbox? Let me know.

Have a great day.

P.S. If i werent clear on some points. If there is a bug in my code, or just things that i have skipped over, please let me know as well.

Whole code without comments

defmodule MyAppWeb.TimeLive do
  @moduledoc """
  A module that renders and handles all interactions from ui
  """

  use Phoenix.LiveView

  @doc """
  Renders this simple html with ony one plain varaible output
  """
  def render(assigns) do
    MyAppWeb.LiveView.render("time.html", assigns)
  end

  def mount(_session, socket) do
    :timer.send_interval(1000, self(), :tick)

    socket =
      socket
      |> assign(:timezones, Tzdata.zone_list())
      |> assign(:changeset, LocalTime.timezone_changeset(%{timezone: "Etc/UTC"}))
      |> handle_tick()

    {:ok, socket}
  end

  def handle_info(:tick, socket) do
    {:noreply, handle_tick(socket)}
  end

  def handle_event("update_timezone", %{"local_time" => new_timezone}, socket) do
    socket =
      socket
      |> assign(:changeset, LocalTime.timezone_changeset(new_timezone))
      |> handle_tick()

    {:noreply, socket}
  end

  defp handle_tick(socket) do
    assign(socket, :changeset, LocalTime.update_time(socket.assigns.changeset))
  end
end

defmodule LocalTime do
  use Ecto.Schema

  alias Ecto.Changeset

  embedded_schema do
    field :timezone, :string
    field :time, :string
  end

  def timezone_changeset(params \\ %{}) do
    %__MODULE__{}
    |> Changeset.cast(params, [:timezone])
    |> Changeset.validate_inclusion(:timezone, Tzdata.zone_list())
    |> Map.put(:action, :insert)
  end

  def update_time(%Changeset{valid?: false} = changeset) do
    changeset
  end

  def update_time(%Changeset{changes: %{timezone: timezone}} = changeset) do
    {:ok, time} = DateTime.now(timezone)
    Changeset.force_change(changeset, :time, time)
  end
end

#
<%= f = form_for @changeset, "#", [phx_change: "update_timezone"] %>
  <%= select f, :timezone, @timezones, promt: "Select timezone" %>
  <%= error_tag f, :timezone %>
  <%= text_input f, :time, readonly: true %>
</form>

Discussion

pic
Editor guide