LiveView with complex layouts

I’ve just played a bit with this idea.
Now that I’ve written this example code it feels almost trivial but for some reason this pattern didn’t click for me yet.

Here is a minimal example of how it could work:

defmodule LabsWeb.TestLive do
  use LabsWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    Process.send_after(self(), :update, 1000)
    {:ok, assign(socket, :counter, 0)}
  end

  @impl true
  def render(assigns) do
    ~L"""
      <%= live_component @socket, LabsWeb.Layout, header: &header/1, main: &main/1, counter: @counter %>
    """
  end

  def header(assigns) do
    ~L"""
    header: <%= @counter %>
    """
  end

  def main(assigns) do
    ~L"""
    main: <%= @counter %>
    """
  end

  @impl true
  def handle_info(:update, socket) do
    Process.send_after(self(), :update, 1000)
    {:noreply, assign(socket, :counter, socket.assigns.counter + 1)}
  end
end

and the layout:

defmodule LabsWeb.Layout do
  use LabsWeb, :live_component

  def render(assigns) do
    ~L"""
    layout
    <%= @header.(assigns) %>
    <%= @main.(assigns) %>
    """
  end
end

A disadvantage of this is that the layout needs to pass all the assigns to the respective components, but with some naming conventions this shouldn’t be an issue.
I don’t think there are other downsides or pitfalls to this (?) so I’m definitely going to explore this path more.

3 Likes

That’s not entirely correct if I understand this correctly. Here new LiveViews are rendered. Or doesn’t this start a new proces?

They need the same state, so I want them to live in the same process. I hadn’t discovered the pattern with assigning functions in components (see my previous post) so with this new knowledge the problem is basically solved.

As I see the PageBuilder is a plain module with callbacks that only look like liveview. Those callbacks get called under PageLive process (under its handle_*; those maybe_apply_module calls), maybe ask José to confirm.


A render function doesn’t require all assigns, try:

<%= header(Map.take(assigns, [:count]) %>

You can also do this

  def header(%{page: page} = assigns) do
    case page do
      :about -> render_about(Map.take(assigns, [:navs]))
      _ -> ~L""
    end
  end
  
  def render_about(assigns) do
    ~L"""
      <%= for nav <- @navs do %>
        <%= nav %>
      <% end %>
    """
  end

ps; I didn’t test the above code, but i have written a bunch of those.


Finally view functions aren’t needed to be passed from a wrapper component as props (JSX mentality), view/render functions can be in a plain module (Just need the import Phoenix.LiveView, to use ~L sigil and have at least a assigns variable as parameter)

That’s not what the docs say: https://github.com/phoenixframework/phoenix_live_dashboard/blob/9354c22228a505868198db4d5a55ac003fa2ae78/lib/phoenix/live_dashboard/page_builder.ex#L5

Also there’s a macro that imports LiveView but haven’t looked at it in detail.

I agree, but like this the layout knows about what the header needs and that seems like terrible design and won’t scale.

Having header_assigns and pass those down is probably the way I would go.

The goal is that each LiveView can care about rendering the header content without having to care about the layout.

Yeah, looks like actions must be atoms. I guess we’ll have to stick to atoms such as :messages_index :messages_edit

My solution is definitely making me think of Elm now. I got things working for a while but then I bumped into the fact that you don’t have access to routing inside nested live views. (Views that aren’t mounted via the router.) I started experimenting with just one single live view at the root and using LiveComponent for anything else.

All my routes now point to a single live view:

# router.ex
live "/", PageLive, :index
live "/nutrition/", PageLive, :nutrition
live "/nutrition/:food_id", PageLive, :nutrition_detail

I do wish actions could be tuples. This would make it easier to pattern match within the hierarchy

For each sub-route, (In my case only :nutrition but more will follow), I define a state entry in the socket’s assigns and pass all messages and information to the corresponding sub-route module.

handle_info and apply_action both use pattern matching to select the correct module to handle the update:

# index.ex
defmodule FireweedWeb.PageLive do
  use FireweedWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket |> assign(page: :index, nutrition: %{})}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Home")
  end

  defp apply_action(socket, action, params) when action in [:nutrition, :nutrition_detail] do
    FireweedWeb.Nutrition.Index.apply_action(socket, action, params) |> assign(page: :nutrition)
  end

  @impl true
  def handle_info(message, socket) do
    case elem(message, 0) do
      :nutrition -> FireweedWeb.Nutrition.Index.handle_info(message, socket)
      _ -> {:noreply, socket}
    end
  end
end

In components I make use of phx-target to force component modules to handle events.

My main template pattern matches against @page (which is set in apply_action):

# index.html.leex
<%=
  case @page do
    :nutrition -> live_component(@socket, FireweedWeb.Nutrition.Index, id: "nutrition", nutrition: @nutrition)
    _ -> "Hello World"
  end
%>

I’m passing the .nutrition state to it so that updates to that slice will propagate.

In FireweedWeb.Nutrition.Index's mount I initialize state:

# /nutrition/index.ex
defmodule FireweedWeb.Nutrition.Index do
  use FireweedWeb, :live_component

  @impl true
  def mount(socket) do
    {:ok,
     socket
     |> assign(
       nutrition: %{
         foods: [],
         food_id: nil,
         food: nil,
         query: ""
       }
     )}
  end

I’ll probably add a initial_state method that is called in the live view to initialise the .nutrition slice state. Since the live view passes updates using apply_action this is also defined in my sub module:

...
def apply_action(socket, action, params) do
    case {action, params} do
      {:nutrition_detail, %{"food_id" => food_id}} ->
        send(self(), {:nutrition, :fetch, food_id})
        socket |> assign(nutrition: Map.put(socket.assigns.nutrition, :food_id, food_id))

      _ ->
        socket
    end
  end

Only updating socket.assigns.nutrition using Map.put (I’ll make a helper function for that). For any expected messages handle_info is also defined as long as the first element in the tuple is :nutrition

...
def handle_info({:nutrition, :search, term}, socket) do

With the single live view in place I can route from anywhere in the hierarchy and now have 3 levels of routes with the nutrition sub module handling anything nutrition related be it routing, messages, or actions.

You can also pass extra data in the session, so you can use that as well

Guys, haven’t you considered use the PubSub? We use this approach in our projects without any problems. See below on gif image or here (Just try delete a patient and look at to the header count of patients.)

where we have sidemenu (and also header) as liveview:

# sidemenu_live.ex
  def mount(_params, _session, socket) do
    Phoenix.PubSub.subscribe(ServerWeb.PubSub, @topic)

    socket =
      socket
      |> assign(page: socket.root_view)
      |> assign(pages: pages())

    {:ok, socket}
  end

  defp pages() do
    %{
      "settings" => %{
        "station" => ServerWeb.Settings.StationLive
      },
      "graphs" => %{
        "actual" => ServerWeb.Graphs.ActualLive,
        "cells" => ServerWeb.Graphs.CellsLive,
    ....
  end

# layout/sidemenu.html.leex
...
      <ul class="menu-list">
        <%= for {page, module_page} <- @pages["settings"] do %>
          <li>
            <%= live_redirect to: Routes.live_path(@socket, module_page), class: is_active_menu_item(@page, module_page) do %>
              <span><%= String.capitalize(page) %></span>
            <% end %>
          </li>
        <% end %>
      </ul>
...

And live layout:

# layout/live.html.leex
<p><%= live_flash(@flash, :notice) %></p>
<p><%= live_flash(@flash, :error) %></p>

<%= live_render(@socket, ServerWeb.HeaderLive, container: {:header, []}, id: "header") %>
<div class="hero is-fullheight-with-navbar">
  <div class="columns m-0">
    <aside class="column is-3 p-0">
      <%= live_render(@socket, ServerWeb.SidemenuLive, container: {:sidemenu, []}, id: "sidemenu") %>
    </aside>
    <section class="column is-9 p-0">
      <%= @inner_content %>
    </section>
  </div>
</div>

(Both apps are still WIP.)

3 Likes

I have thought of using PubSub yeah. The router-mounted live view could accept messages to call live_patch and send routing updates (actions, params) back to subscribers.

I’m still learning Elixir and Phoenix so I’m not 100% comfortable with it yet. My instinct is to try and solve this architecture by taking a compositional approach first similar to ReactJS and Elm and add PubSub for state management and sharing later when I have a better grasp of the language, framework and cost of introducing things like PubSub.

You can also pass extra data in the session, so you can use that as well

Those are only passed to mount though aren’t they?

It’s not for state management, but for message passing. There is nothing similar in React, where components would send messages to others directly.

It’s a bit like ReactContext or Redux no? The root live view “manages” router state and updates are communicated to (grand)child views via subscriptions?

Here You can send a message from everywhere, to anywhere… for example a child could send a message to the root, and vice versa.

And a message can be a state-transfer message, a notification message…

That’s why I would not compare it to Context, which is like a shared state between a hierarchy of components.

And somewhere, You can react to those messages, and change your state… à la Redux.

1 Like

It sounds like layouts will be added in future Live View, which will be great.

Meanwhile my code is setup like:

<html lang="en" class="has-navbar-fixed-top">
    <head>
        ...
        <link rel="stylesheet" href="<%= Routes.static_path(@conn,"/css/app.css" ) %>">
    </head>

    <body>
        <!-- START NAV -->
        <nav class="navbar is-info is-fixed-top">
            <div class="navbar-brand">...</div>
       ...
        <section class="hero is-dark is-fullheight-with-navbar">
                <%= render @view_module, @view_template, assigns %>
        </section>
...

Though, the title bar and such is stateless.

I ended up putting shared views such as navigation side menus and headers in live components that are rendered by each top-level live view route component. So far this has been the best working strategy since it allows for more routed live views which makes message handling and state management much less verbose than The Elm Architecture approach that I was experimenting with.

I was hesitant to take this approach because I thought re-rendering the shared components in different live views would cause flickering but I don’t think the DOM parts are actually being replaced.

For anyone looking for an example, I’ve got some code here https://github.com/rjdestigter/fireweed. I’ve been using http://surface-demo.msaraiva.io/ to build components including the Navigation component that is rendered inside each top-level live view.

3 Likes

I went with this approach as well, however, passing data between parent/child views and handling parameters got pretty messy.

So I think using templates might be a better approach as described in this post.

Quick test against your demo deploy, in Safari 14 there is flickering on the nav icons although not the text. Trying it in Chrome & Edge on a Mac, the transitions between LVs are perfectly smooth.

Perhaps Webkit is a little weird with SVGs.

The idea of using Pub/Sub leads me to the following question: when are LiveView components able to receive messages? My underlying concern is: what is the cost of using PubSub for an application having a large number of LiveViews/Components. Here are my specific questions:

  1. If a user of the website visits some route leading to the LiveView MyApp.LiveModule1.Index and this LiveView publishes a state-related message that will be needed by other LiveViews (for example the current user). Then, and only then, the user visits one of these other LiveViews for the first time: will the LiveView already exist and have processed the state-related message, or will it only start processing messages after being loaded at least once.
  2. If the answer to the previous question is that all LiveViews will receive the message without first needing to be explicitly loaded by the router, then isn’t it an issue for large scale applications having hundreds or thousands of LiveViews/Components? (such as the one we are considering building using Phoenix LiveView). Especially when message handlers are meant to immediately assign the state passed through the message (and thus claim memory). This could result in huge amounts of RAM memory being wasted, especially as the number of users grow. On the other hand, if LiveViews will not receive messages before being visited at least once before, then pub/sub is not suitable to manage global state (damn if you do, damn if you don’t sort of).

This state passing/sharing issue across components could end up being a deal breaker for us. Because apart from Pub/Sub, I do not like at all a lot of the solutions I’ve seen discussed in this thread that look very much like the fat controllers and other God-objects produced by junior object-oriented developers. I expected Elixir, Phoenix and functional programming in general to make composition clean and easy (which I feel is the case, expect for this specific problem). I am a bit worried to see that there does not seem to exist a mature solution to this problem, especially since as newcomers to Elixir we we would not have the skills and confidence required to pull out, evolve and maintain arcane clever tricks.

From this perspective, Pub/Sub would be the ideal option for us, but I have the scalability concerns I just mentioned. Maybe the answer to this simply is that processes are very lightweight in elixir so this is not an issue in practice? However this doesn’t address the fact a user of the website would claim memory in hundreds of LiveViews/Components that he may never visit during his session. The only solution I can see to this would be to manually check if the user has visited the LiveView before assigning state but my guts tell me that this may end up being quite messy to maintain (and what about checking this for non-routable LiveViews/components).

Generally speaking, having to worry about this seems to defeat one of the main benefits of composition: every component is focused on its own simple concerns so that complex solutions are built from simple parts. But if every component now has to worry about application infrastructure concerns, this does not inspire confidence in terms of the maintainability of complex systems.

I hope someone can ease these concerns because apart from that Phoenix LiveView is absolutely ideal and exciting for us, whether it is from a performance perspective, or from an Intellectual Property protection perspective. But the need to have shared state is so crucial in UI design that it would be reckless for me to bet our luck on LiveView for such an important project if there is no efficient and elegant solution to this problem, this would be like putting a time-bomb at the foundation of our project.

I used this occasion to contribute the apprehensions and thought process of an experienced teams mainly working in OOP ecosystems and looking at Elixir and Phoenix, since I believe an increasingly number of teams will start considering Phoenix and Elixir for their front-end moving forward, so hopefully this feedback can be useful to you.

@reddy, when a user visits the website a process is created. A single process per connection. The server does not start a process for every view and component per user because, as you describe, this would be wasteful, pointless, use too many resources etc.

This answers the other question, which is now “do processes that haven’t been created receive events?” Obviously, they cannot. Phoenix.PubSub messages are not kept around so that it can forward them on to future subscribers. That is not pubsub, that is more like a message queue/broker (think rabbitmq or Kafka, which won’t keep them forever either) or event sourcing (where you could load all the previous messages from a store). Pubsub just shoots message off into the abyss.

So, what do you do? In your LiveView’s mount, you load whatever data you need to populate the page and subscribe to whatever topics you need updates from.

Edit: also, components cannot receive pubsub messages, only LiveViews. You can look at the livebeats source, which solves the how do I message the navigation bar quandary rather elegantly.

6 Likes