Active nav with LiveView · The Phoenix Files

Berenice Medel

One of the most important challenges when developing a new website is to give the user a great navigation experience. The user must know where they are and what navigation options they have at their disposal within the website. For this, we use navigation components such as navbars or sidebars, and developers face the challenge of showing the user their current position in a clear and intuitive way.

Today I’ll share a recipe for handling navigation within our Liveview applications, and how we can offer a navigation bar that shows the active tab the user is currently on.

Defining the sidebar markup

First, we’ll define the markup of the application sidebar within our live layout in live.html.heex, which will be part of the lifecycle of our LiveView components.

<%= if @current_user do %>
  <.sidebar_nav current_user={@current_user} active_tab={@active_tab}/>
<% end %>

Let’s take a closer look at the previous code; there are two assigns defined, @active_tab and @current_user. We are passing these as parameters to a function component called sidebar_nav_links, defined in the LiveBeatsWeb.LayoutView module as follows:

def sidebar_nav(assigns) do
  ~H"""
  <nav class="px-3 mt-6 space-y-1">
    <%= if @current_user do %>
      <.link
        navigate={profile_path(@current_user)}
        class={"#{if @active_tab == :profile, do: "bg-gray-200", else: "hover:bg-gray-50"}"}>
        <.icon name={:music_note} outlined />
        My Songs
      </.link>

<.link
        navigate={Routes.settings_path(Endpoint, :edit)}
        class={"#{if @active_tab == :settings, do: "bg-gray-200", else: "hover:bg-gray-50"}"}>
        <.icon name={:adjustments} outlined/>
        Settings
      </.link>
    <% end %>
  </nav>
  """
end

We are using the sigil_H that returns a rendered structure containing two links: one for My Songs and another for Settings. Each link has a CSS class defined depending on the value of @active_tab.

"#{if @active_tab == :profile, do: "bg-gray-200", else: "hover:bg-gray-50"}"

This way, the user perceives their current location within our navigation options.

Setting the @active_tab assign

We have defined a list of active_tabs to assign a conditional CSS class to indicate the user's location within our application. Now we need to see how the active_tab and current_user assigns are determined. We also need this assign available in all LiveViews since it is rendered in the live layout. Luckily, LiveView lifecycle hooks make this easy.

Let’s take a look at the following code inside router.ex:

live_session :authenticated, on_mount: [
  {LiveBeatsWeb.UserAuth, :ensure_authenticated},
  LiveBeatsWeb.Nav
] do
  live "/:profile_username/songs/new", ProfileLive, :new
  live "/:profile_username", ProfileLive, :index
  live "/profile/settings", SettingsLive, :edit
end

We see that a live_session is defined as :authenticated, which includes a user validation process. If the user is valid, the content will be shown; otherwise, they will be redirected to the login page.

The first hook handles user validation through the on_mount callback of the LiveBeatsWeb.UserAuth module:

 def on_mount(:ensure_authenticated, _params, session, socket) do
    case session do
      %{"user_id" => user_id} ->
        new_socket = LiveView.assign_new(socket, :current_user, fn ->
            Accounts.get_user!(user_id)
          end)
        {:cont, new_socket}
      %{} ->
        {:halt, redirect_require_login(socket)}
    end
  rescue
    Ecto.NoResultsError -> {:halt, redirect_require_login(socket)}
  end

In line 4, current_user is assigned to the socket assigns if the current user is found. If not, they are redirected to the log-in view.

The second hook sets the assigns needed to show the active tab in the sidebar.

defmodule LiveBeatsWeb.Nav do
  import Phoenix.LiveView

def on_mount(:default, _params, _session, socket) do
    {:cont,
     socket
     |> attach_hook(:active_tab, :handle_params, &set_active_tab/3)}
  end

defp set_active_tab(params, _url, socket) do
    active_tab =
      case {socket.view, socket.assigns.live_action} do
        {ProfileLive, _} ->
          if params["profile_username"] == current_user(socket) do
            :profile
          end

{SettingsLive, _} ->
          :settings

{_, _} ->
          nil
      end

{:cont, assign(socket, active_tab: active_tab)}
  end

defp current_user(socket) do
    socket.assigns.current_user[:username]
  end
end

In line 4, we define the on_mount callback that will be used as :default. The important part of this callback can be seen in line 7, where we call attach_hook/4. We named our hook :active_tab and attached it to the handle_params stage of the socket lifecycle, invoking our set_active_tab/3 function.

In set_active_tab/3, logic is implemented to set @active_tab based on parameters, the LiveView module, and live action from the router. This allows the user to navigate through LiveViews, with the tabs highlighting appropriately as the URL changes.