Tabbed interface with multiple LiveViews?

Hi!

I’ve got a tabbed interface currently with two separate LiveViews, each with their own live route (e.g. live "/one", OneLive and live "/two", TwoLive in the router).

The live layout uses live_redirect for the tabs that switch between the two.

I don’t love the slight delay when clicking a link and a LiveView re-mounts. I’d like for both LiveViews to stay loaded for the duration of the user’s session.

What’s a good option here to load both LiveViews at the same time but let me navigate between them?

Put them both in the same non-live page with two <%= live_render … %> and make JS tabs to hide/show one or the other? And write my own push state stuff if I want the URL to be different?

Or perhaps nested LiveView components, where the outer one is responsible for the tabs and does push state stuff to the URL via LiveView hooks?

1 Like

I am doing it LiveComponents in my app. The parent LiveView does a simple mapping from the current action to the component name, similar to how the phx.gen.live uses the action to enable the modal. The router looks like this:

live "/one", TabbedLive, :tab1
live "/two", TabbedLive, :tab2

Then you can use live_patch between them, but you shouldn’t really see such a drastic difference compared to live_redirect.

10 Likes

Thank you, José! Sounds like a good way of doing it - will give that a shot and see how it compares.

Started experimenting with this.

I’m realising that turning my LiveViews into components makes them a little more work, because I’ll have to pass their state in from the parent TabsLive, have to explicitly tell them to pass events to @myself, and they don’t have a handle_info.

But I guess that’s the trade-off – if I want to switch between multiple live thingies without re-mounting them, I assume they need to be components within a parent LiveView. I guess there’s no way to switch between two different LiveViews without unmounting/remounting, other than if I render both on a non-live page and hide/show them?

Just to check that I’ve understood the idea correctly (and if it helps someone else who finds their way here), this is what I’ve got so far.

Routes:

live "/one", TabsLive, :one
live "/two", TabsLive, :two

With this navigation in the live layout:

<%= live_patch("One", to: Routes.tabs_path(@socket, :one)) %>
<%= live_patch("Two", to: Routes.tabs_path(@socket, :two)) %>

And this in TabsLive:

defmodule MyWeb.TabsLive do
  use MyWeb, :live_view

  def render(assigns) do
    ~L"""
    Hello: <%= @live_action %>

    <%= case @live_action do %>
    <% :one -> %>
      <%= live_component @socket, MyWeb.OneComponent, id: :one %>
    <% :two -> %>
      <%= live_component @socket, MyWeb.TwoComponent, id: :two %>
    <% end %>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  # Needs to be defined for re-rendering to happen.
  def handle_params(_params, _uri, socket) do
    {:noreply, socket}
  end
end
3 Likes

Seems like I can do

<%= live_render @socket, RemitWeb.OneLive, id: :one %>

instead of

<%= live_component @socket, MyWeb.OneComponent, id: :one %>

Then when I navigate between the tabs, it does mount the components again, but it no longer does an Ajax request to “/one” like it did when I used live_redirect … for the links in the layout.

I can go even further and have TabsLive do

def render(assigns) do
  ~L"""
  <div style="display: <%= if @live_action == :one, do: "block", else: "none" %>">
    <%= live_render @socket, MyWeb.OneLive, id: :one %>
  </div>

  <div style="display: <%= if @live_action == :two, do: "block", else: "none" %>">
    <%= live_render @socket, RemitWeb.TwoLive, id: :two %>
  </div>
  """
end

Then it only mounts them once and hides/shows them, but all within LiveView.

When both LiveViews run at the same time, they can’t be relied on to set page_title anymore (the one that isn’t visible could suddenly set it based on some background event), but that responsibility fits well in a TabsLive anyway:

def handle_params(_params, _uri, socket) do
  socket =
    case socket.assigns.live_action do
      :one ->
        assign(socket, :page_title, "One")
      :two ->
        assign(socket, :page_title, "Two")
    end

  {:noreply, socket}
end

Would love some feedback on how sane this solution is :smiley:

3 Likes

They can build their own state - you don’t have to do it in the parent. That’s what what I do in my case. Although you are correct on the @myself and handle_info bit.

You shouldn’t need to define mount/3 and handle_params/3 in the parent LiveView either. If you have to do so, it is definitely a bug, please open up an issuie. :slight_smile:

Regarding your new approach, it definitely works fine, but it does mean more processes are spawned.

5 Likes

Could you please show how to implement this use case in a better/efficient way?

Maybe you want to see how Surface implements tabs under the hood and replicate it, or just use Surface, that is an abstraction for web components on top of live view, made by @msaraiva.

4 Likes

Nice! Thank you for great example!

1 Like