LiveView with complex layouts

So I’ve read a lot and experimented with and used LiveView a lot, but there is one thing I can’t wrap my head around.

I have an application like this:

+--------------------------------------------------------+
|                                                        |
|                         Header                         |
|                                                        |
+----------------+---------------------------------------+
|                |                                       |
|                |                                       |
|  Navigation    |                  Main                 |
|                |                                       |
|                |                                       |
|                |                                       |
|                |                                       |
+----------------+---------------------------------------+

With these requirements:

  • multiple liveviews need to use this layout
  • the layout is quite complex so I’d rather not duplicate it in every liveview template.
  • The Navigation shows the current selected item.
  • Header and Main need access to all assigns of the current liveview.

At the moment I took some inspiration from the live dashboard on how to create the navigation.
My current solution looks like this:

I have a parent page that now renders the layout

# simplified the layout
    <div>
      <%= live_component @socket, NavLive, links: @links, current_user: @current_user %>

      <header>
      <%= render_header(@socket, @page, assigns) %>
      </header>
    </div>

    <main>
          <%= live_render(@socket, @page, id: @page, session: %{"live_action" => @live_action, "params" => @params, "current_user" => @current_user}) %>
    </main>

live_render will then render the child based on the current route and render_header will also call a method on my child view.

The problem with this solution is:

  1. render_header is a custom method and doesn’t have access to the assigns created in the mount of the child view
  2. I have to do some ugly things to pass the live_action, current_user around to child views - but this works.

(1.) is thus the main problem. How do you people solve this?

In React I would probably create a component ApplicationLayout that takes 3 props: Header, Navigation and Main and every page could use that.

<ApplicationLayout
  header={<div>custom html</div>}
  navigation={<div>custom html</div>}
  main={<div>custom html</div>}
/>

I don’t see how this can be done in liveview, unless I’m missing something?
How do you build these kind of complex layouts?

4 Likes

I’m struggling with this as well. I’ve added a common top bar component to my live.html.leex template but now I’m trying to figure out how to respond to page title changes. Similarly, if I added a side bar, how would I have access to routing information?

I have a feeling it comes down to state management and being able to share information between different live view processes but I’m not sure how to tackle this.

1 Like

This thread is shedding some light for me on this topic: Multiple LiveView. Share state across them via common socket

Thanks, that thread and the link to Elixir/Phoenix-like C# gives a lot of great info as well. I hadn’t come across these.

It seems that the way I’m currently taking is close to what is described in that thread, I’m mostly missing subscribing to the parent and passing messages like that to the children.

Another option would probably be to have only one liveview and do everything in components. I would need to think about how I would handle all the logic.
Are there people who use this pattern? One liveview only with components?

Would it be useful to add these patterns in some way to the LiveView documentation?

That is exactly the layout I was going for.
My header is a menu that was put in the template and live-redirects to different pages.

The live pages then loaded two components, the side menu component with each menu item doing a live_patch with different actions and, depending on the live action, the component shown in the Main area.

Now I am running into the problem of setting the sign in/out button in the top menu cause I do not see how to access any information that i can use to determine if the user is logged in or not.

Why not put the layout inside the templates of the various page-specific LiveViews? You could write a simple function (or indeed a stateless LiveComponent) which takes HTML for the header, navigation and body as arguments and finally creates the constructed HTML combining all that in a full page from those arguments.

Usually when two modules have to have access to common things, they are going to be in a third module, either as sibling or parent. Just like phoenix context or generally module management. I believe liveview processes/states are managed the same way.

The article https://www.theerlangelist.com/article/spawn_or_not can also be applied (to live_render or to live_component). In backend system, fault tolerance makes more sense than GUI (sections). So make sure that on GUI, sections are really independent, more often they are coupled. When one section is crashed, the rest would need to refresh their stale presentation too.

I personally start with a root liveview process, and GUI sections are live_components, but what I write the most is def render_function(assigns), do: ~L"". Once I have @current_user tracked, I have to think twice why would I want to start fresh (live_redirect), instead of live_patch?. I write Elm at the same time and am used to thinking no local state or even “component” at all. Function is a good friend.

1 Like

So when defining routes that render inside your root liveview, do you point all those routes at the liveview but different actions?

2 Likes

I think I figured it out. You define your routes using the same liveview but with different actions. Then in handle_params you “look at” the incoming action and any url parameters and make a decision there on how to update the assigns on the socket.

In your (root) liveview’s template you just keep pushing down assigns to other components and liveviews.

2 Likes

Exactly, the mentality is the same as client SPA routing (see: package.elm-lang.org/src/frontend/Main.elm at master · elm/package.elm-lang.org · GitHub). Sections within a page barely need multiple processes. OP took some inspiration from Live Dashboad, but even Live Dashboard has only 1 process which is PageLive.

Given OP’s GUI layout, I have to ask myself why <main> needs a separate process instead of live_component with the following requirement:

Not sure if this is the need to access same set of state/truth or the need to use the same data structure which would be a share module in a phoenix context. If it’s the former case they should live in the same process, right?

render_header will also call a method on my child view.

I’d love to see what the exact method is, to understand more about the relationship between the header and the main.

1 Like

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: phoenix_live_dashboard/lib/phoenix/live_dashboard/page_builder.ex at 9354c22228a505868198db4d5a55ac003fa2ae78 · phoenixframework/phoenix_live_dashboard · GitHub

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.)

fuelcell

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?