LiveView shared layout + live components woes

Intro: what and why

I’ve searched more or less high and low, but now one seems to have a good answer on how to properly implement a layout that implements things like:

  • live_patch for links
  • live_render to embed various live components
  • works across all views and controllers

The reason for such a layout is simple: imagine a CMS system. You may want to have:

  • a sidebar with all the links to internal functionality: list all pages, edit a page, manage uploaded media, manage users. All these will be handled by different controllers. But you would still want a live_patch link to relevant pages

  • in that same sidebar (or some other place like a sidebar to the right, or a footer, or a notification pop up) you want to place all manners of various things: your internal console, a list of things that are happening in the system (for example, you’re batch-processing a bunch of images and you want to see the status of the job even as you’re working on something else in the system) etc.

The problem

The problem is: how to achieve all that?

The setup

So, let’s assume you have the following in your router.ex

  pipeline :admin do
    plug :authenticate_user
    plug :put_root_layout, {CMS.LayoutView, :admin}
  end

  scope "/admin", QuireWeb do
    pipe_through [...all your other pipleines ..., :admin]
  end

You have the following in your CMSWeb.AdminController:

defmodule CMSWeb.AdminController do
  use CMSWeb, :live_view
  use CMSWeb, :controller

def handle_params(_params, _uri, socket) do
  {:noreply, socket}
end

def mount(_params, _session, socket) do
  {:ok, Phoenix.LiveView.assign(socket, some_data: [])}
end

def render(assigns) do
    ~L"""... render your live data as you would..."""
end

You have the following in your CMSWeb.ProcessesView:

defmodule CMSWeb.ProcessesView do
  use CMSWeb, :live_view

 def render(assigns) do
    ~L"""... render live data ..."""
 end
 def mount(_params, _, socket) do
    ## You would also subscribe to a PubSub queu here, and have
    ## multiple handle_info callbacks to update the view
    {:ok, assign(socket, some_data: [])}
 end

The layouts, the data flow and issues

The rendering goes through these layers

1. Root layout: can render live components, live_patch is broken

The root layout will be template/lyout/admin.html.leex (irrelevant code omitted for clarity):

<!DOCTYPE html>
<html lang="en">
  <head></head>
  <body>
     <%= live_patch "All pages", to: Routes.live_path(@conn, CMSWeb.AdminController) %>
     <%= live_render(@conn, CMSWeb.ProcessesView) %>

     <%= @inner_content %>
  </body>

2. Live layout: inclusion of live_render ends up messing up live_patch

Let’s remove both live_patch and live_render from our root template, and add them to template/live.html.leex:

<%= live_patch "All pages", to: Routes.live_path(@socket, CMSWeb.AdminController) %>
<%= live_render(@socket, CMSWeb.ProcessesView, id: "unique-id") %>

<%= @inner_content %>

First, the changes:

  • @conn is no longer available, you use @socket
  • live_render requires an id because the component is now rendered within the context of an existing LiveView

Problems I encountered:

  • if you include the live_render in this layout, all live_patch links get broken and do a full page reload instead of a live update. This even includes live_patch links rendered by LiveView inside @inner_content
  • if you keep live_render in the root layout, no problem with live_patch, but then you can’t really have the layout you need

3. The controller/view

And the last step in the rendering is the actual view returned by your controller. The problem having a layout on this level is obvious:

  • you need to copy-paste/include the layout in every separate view
  • events sent to the live component will end up being sent to the currently active controller

Halp? :slight_smile:

So, I’m stumped. And I can’t figure out a good way to do this. I looked at LiveView dashboard, but I doubt this is an approach that everyone will like or can use: phoenix_live_dashboard/page_builder.ex at master · phoenixframework/phoenix_live_dashboard · GitHub

Currently, sticking everything into the root layout kinda works, and is the least hassle, but doesn’t feel right.

7 Likes

I think part of your confusion here is using live_render when what you probably want is live_component. Multiple live views on the same page is not really a pattern most people are finding useful. I’m also a bit confused by your question about controllers, since if you’re using live layout there isn’t really a controller involved.

1 Like

I think part of your confusion here is using live_render when what you probably want is live_component.

Trying to use live_component inside live.html.leex fails for me with

no function clause matching in Phoenix.LiveView.Helpers.__live_component__/3

Multiple live views on the same page is not really a pattern most people are finding useful.

Imagine any sufficiently complex app. You will always have some parts of the page that are the same regardless of where you are inside the app, and that need to be updated regardless of where you are in the app. Examples:

  • Gmail shows the number of unread messages in the left sidebar. These numbers are updated regardless of whether you’re composing a new email, or reading an email

  • Also Gmail: has a pop-up message composing window that’s available everywhere

  • Elixir Forum. The avatar in the top-right corner will update if there are unread messages, you can click on it and see the list of unread messages. You can also search in the pop-out in the top-right corner. All this happens regardless of where you are in the forum.

And so on. People do really want various live components on the page that have to stay the same on any page. So, it makes sense to push them into the layout. But the layout has issues like not clearing phx-click-loading events from live_patch links (and probably have other limitations). And I haven’t found any good ways of implementing this except what I’ve written.

I’m also a bit confused by your question about controllers, since if you’re using live layout there isn’t really a controller involved.

I guess this is about step 3? When you land on a page, the actual contents of the page will be rendered by a controller/view. That’s the @inner_contents of the page.

1 Like

More insights: layouts and flash

Given all the set up in the original post, let’s add flash in various places:

<p class="alert alert-info" role="alert"
   phx-click="lv:clear-flash"
   phx-value-key="info">aaa<%= live_flash(@flash, :info) %></p>

Putting flash in admin.html.leex

It will display only once, which is a good thing. However, phx-click will not trigger, and the flash will not be cleared:

admin.html.leex

<p class="alert alert-info" role="alert"
   phx-click="lv:clear-flash"
   phx-value-key="info">aaa<%= live_flash(@flash, :info) %></p>

<%= live_render(@conn, CMSWeb.ProcessView) %>
<%= @inner_content %>

live_render in admin.html.leex, live_flash in live.html.leex

Since putting live_render inside live.html.leex breaks live_patch, I tried to do the following:

admin.html.leex:

<%= live_render(@conn, CMSWeb.ProcessView) %>
<%= @inner_content %>

live.html.leex:

<p class="alert alert-info" role="alert"
   phx-click="lv:clear-flash"
   phx-value-key="info">aaa<%= live_flash(@flash, :info) %></p>
<%= @inner_content %>

Now, if you put_flash, it will display twice: once in the live_rendered component and one in the regular page. Which makes sense since it’s two LiveViews going through the same layout.

phx-click works, but you have to dismiss each flash separately.

My current solution is: render whatever common live parts I need in the root layout:

  • live_render works with no issues
  • live_patch works with no issues. I only overrode the phx-click-loading CSS class:
.phx-click-loading {
  transition: none;
  opacity: inherit;
}
  • for flash I’m also live_rendering a separate component that only displays the flash. It’s not ideal, and can be folded into the other common live components. But since I’m at the prototyping stage, it’s fine for now.

So the layouts are something like this:

admin.html.leex

<!DOCTYPE html>
<html lang="en">
  <head> ... code omitted for clarity </head>
  <body>
     <navbar>
        <%= live_patch "All pages", to: Routes.live_path(@conn, QuireWeb.AdminController) %>
     </navbar>
     <main>
          <%= live_render(@conn, CMSWeb.FlashView) %>
          <%= live_render(@conn, CMSWeb.ProcessView) %>
          <%= @inner_content %>
     </main>
  </body>
</html>

live.html.leex is now only

<%= @inner_content %>

And the flash view is as expected:

defmodule CMSWeb.FlashView do
  use CMSWeb, :live_view

  def render(assigns) do
    ~L"""
        <div class="alert alert-info" role="alert"
             phx-click="lv:clear-flash"
             phx-value-key="info"><%= live_flash(@flash, :info) %></div>

        <div class="alert alert-danger" role="alert"
             phx-click="lv:clear-flash"
             phx-value-key="info"><%= live_flash(@flash, :info) %></div>
    """
  end

  def mount(_params, _, socket) do
    {:ok, socket}
  end
end

I won’t go into too much detail as you have seemed to have solved your problem, but I use a stateful component with a block for layout between LiveViews.

defmodule MyAppWeb.Layout do
  use MyAppWeb, :live_component

  def render(assigns) do
    ~L"""
    <div>
      <header>
        <h1>My App</h1>
      </header>
      <div class="content">
        <%= render_block(@inner_block) %>
      </div>
      <footer>
        <p>I'm some stuff in the footer.</p>
      </footer>
    </div>
    """
  end
end

defmodule MyAppWeb.HomeLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~L"""
    <%= live_component @socket, MyAppWeb.Layout, id: "layout" do %>
      <p>I'm some content wrapped in a layout.</p>
    <% end %>
    """
  end
end
6 Likes

Very nice! Thank you for a suggestion!

1 Like

100% agreed, but that doesn’t mean that they are dedicated live views distinct from the root view.

It’s worth keeping in mind that the default structure of LiveView is not a SPA. It is a page by page oriented structure that provides some affordances to change pages without having to reconnect.

If you want the full SPA experience, I don’t think multiple live views is the way to go. Rather, the better option is to create a single root live view that uses handle_params to conditionally switch out a core central live component that represents the unique page content. Notification drawers and other persistent nav items can then simply remain standard live_components (not dedicated live views) and are managed from that root view.

8 Likes

My use case is that I have my main app and a settings page (which is growing). I feels like a lot to hold all that state in one LiveView so that’s why I go with my solution above to share layout between the app and settings. Is there a better way to do this?

Ah. Now I see what you mean. Somehow I never thought about such an approach. Thank you!

1 Like

Can you elaborate on this? The method I was talking about doesn’t require you keep the state of more than one page loaded at any given time. Each main live component would load only the state that is required for it, and then it is dumped when it is unmounted.

My understanding of live components is that the state still has to be stored on the live view. Have I got this wrong??? Like, I know I can instantiate data in a component, but then the state would need to be sent back to live view (process) to hold onto?

LiveComponents can now be stateful, and manage state not contained in the parent live view*. That is to say, the live view can fetch basic stuff, and then call live_component(@socket, Foo) and then Foo can go lookup further information that it assigns to itself and is not visible in the assigns within the live view.

*As a matter of Elixir Processes it’s all inside one process. However, the assigns are only visible in the live component, and the data is removed when the component is unmounted without you having to do anything in particular.

1 Like

Sidebar as a LiveComponent in layout works fine in my project.

in layout/live.html.leex:

<%= live_component(@socket, CgWeb.Live.Sidebar, id: "cg-sidebar") %>

in live/sidebar.html.leex:

...
    <%= for a <- @menu do %>
        <div style="line-height: 50px;<%= if a.path == @path, do: "background-color: #DCFFFF;", else: "background-color: #001529;" %>">
          <%= live_patch a.text, to: a.path, style: "color: #{if a.path == @path, do: "black", else: "white"}; " %>
        </div>
    <% end %>
...

and just send a update to sidebar, when liveview page mounted:

send_update(CgWeb.Live.Sidebar, id: "cg-sidebar", path: "/home")

After few hours of struggle trying to use a single live as a sidebar, I realized the hard thing is keep data consistent among processes. If you just want to use same data in different part of a single page, live component is the ideal choice.

1 Like

The downside is that live components can’t subscribe to PubSubs, the pubsubs will go to the parent process.

2 Likes

I trying to do the same thing.

Let’s say we have:

  • a page consist of a header (includes authentication functionality) and a content part
  • different teams building different pages
  • all pages need the same header

With live components every page needs to get user information in order to render the header live component. Which makes no sense in my eyes. There should be a root layout that uses live_render for the different parts of the page and each team just build the content for their page and another team takes care of how to render the header and how authentication works.

Or how else could I achieve that separation of concerns?

2 Likes

Well, every page needs to call a function that fetches user information. It can be the same function on every page. That does introduce a bit of boilerplate, and I believe the Phoenix team is working on some options for defining common pipelines.

As always though, separation of concerns happens at the function layer, and nothing prevents you from extracting the common concern into a common function.

If you have 10 areas then that’s not an option. You can’t and don’t want to know how everything in a big webpage works.

Would be nice just to have an option with Template you want to render the view.

LiveView 0.16 fixes this, you can add a hook via on_mount and load the data for the header there.

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live_session/2

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#on_mount/1

I’m not sure I understand. User data for the layout only needs to be loaded at the root live view, not on every component.

1 Like