Live Sessions in Action

This is a post about the benefits we can get from using live_session, and the super powers we get from combining it with hooks. If you want to deploy your Phoenix LiveView app right now, then check out how to get started. You could be up and running in minutes.

Phoenix LiveView often makes us feel like "wow, that was really fast!" and that is not a coincidence. Behind LiveView's magic, there are a bunch of design decisions, but also interesting features we can use.

Under the umbrella of LiveView navigation we have the live_session/3 macro to group live routes into live sessions. We can navigate between the routes in the same session over the existing websocket, without any additional HTTP request, thus making navigation between live routes even faster!

But live_session also has some other interesting ramifications. Today we'll take a closer look at this feature and see that it has more going for it than is immediately obvious.

Live_session in Action

When we navigate between different pages within our application using LiveView, every time we want to mount a new root LiveView, an HTTP request is made. Then, a connection with the server is established and the LiveView to be rendered is mounted. Which means that a full page reload is being done.

If we look at our iex console, each of these steps is described in the logs:

Wouldn't it be better if we could switch between LiveViews without making any HTTP requests (saving a couple ms in the process)? That's when live_session comes into play!

Let's start learning how to group different live routes using live_session.

In our router.ex file, we define a live_session and give it an atom as a name:

scope "/", MyAppWeb do
  pipe_through :browser

  live_session :default do
    live "/users/register", UserRegistrationLive, :new
    live "/users/log_in", UserLoginLive, :new
    live "/users/reset_password", UserForgotPasswordLive, :new
  end
end 

This way, we can navigate between each route within our :default session without any additional HTTP requests, using live_redirect in our LiveView's templates:

<%= live_redirect "Register", to: "/users/register" %>

Let's see it in action:

Every time we navigate through our authentication system a new LiveView is mounted, but this time, as we can see in the logs, there is no additional HTTP request!

This is nice and fast. But the separation between routes in different sessions is where we start to see more possibilities!

Attaching Hooks to a Group of Routes

We can attach hooks in the mount lifecycle of each LiveView in the session just by combining the live_session macro with the on_mount callback.

First, we define on_mount functions that will be invoked on our LiveView's mount. For example:

defmodule MyAppWeb.UserAuth do

  def on_mount(:default, _params, session, socket) do
    {:cont, socket}
  end

  def on_mount(:ensure_user_is_admin, _params, session, socket) do
    if session["current_user_role"] == "admin" do 
      {:cont, socket}
    else
      {:halt, socket}
    end
  end
end

Our :ensure_user_is_admin hook stops the mounting process if the user is not an admin, and continues the process otherwise. These outcomes are accomplished by returning the tuples {:halt, socket} and {:cont, socket}, respectively.

Once our hook is defined, we can attach it to our session by using the on_mount option. We pass a tuple with our module's name and the name of the hook we defined above:

live_session :admin,
  on_mount: {MyAppWeb.UserAuth, :ensure_user_is_admin} do
  live "/settings", SettingsLive, :edit
  ...
end

What if we want to attach more than one hook per session? we can do it by defining a list of them:

live_session :admin, 
  on_mount: [
    MyAppWeb.UserAuth,
    {MyAppWeb.UserAuth, :mount_current_user}, 
    {MyAppWeb.UserAuth, :ensure_user_is_admin}
  ] do

  live "/settings", SettingsLive, :index
  ...
end

Did you notice that the first element of the on_mount list is different? If you call a hook without specifying a name, LiveView will default to the :default hook.

We can use this to add custom behavior to our routes or to define different authorization strategies.

For example, we can protect routes from unauthenticated users; conversely we can redirect authenticated users from LiveViews that don't make sense for them (like a login page).

Let's see how we can handle this example in a secure way.

Security Considerations for Authorization Strategies

When we have a regular web application and we want to perform authentication and authorization operations on each of the routes in a scope; we usually define plug functions with the necessary validations, then we group them into pipelines, and finally, we pipe each of the routes through those security pipelines.

Thinking in live routes, if we want to secure each of our live routes, is it enough to use the same security pipelines we define for regular routes? Let's think about it.

When we first mount a LiveView within a session or redirect between different sessions, an HTTP request is made. Which means, all our routes will pipe through our security pipelines. That's good!

However, what happens when we navigate between LiveViews within the same session? The same stateful connection is used to mount the new LiveView, and no HTTP requests are made. Which means: no security pipelines at all!

So, if we want to secure each of the routes within a session, we must do it in another way. Fortunately, we've already learned how to do it: we just have to define all our authorization logic inside hooks!

Let's do it:

scope "/", MyAppWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  live_session :only_unauthenticated_users,
    on_mount: [
      {MyAppWeb.UserAuth, :redirect_if_user_is_authenticated}
    ] do
    live "/users/register", UserRegistrationLive, :new
    live "/users/log_in", UserLoginLive, :new
    live "/users/reset_password", UserForgotPasswordLive, :new
  end

  post "/users/log_in", UserSessionController, :create
end

In order to secure all the routes in the / scope, we applied both security mechanisms we mentioned earlier. Plugs/pipelines to secure web requests, and hooks to secure each of the routes in the session, on the LiveView's mount.

Using a Different Root Layout for Grouped Like Routes

Navigating among live routes within a single live session lets you avoid the overhead of a full page reload. This prevents the root layout from changing, which you should keep in mind when grouping LiveViews into live sessions!

Conversely, putting routes into different live sessions forces a page reload through the plug pipeline on navigation between them. This is an opportunity that live_session doesn't waste: it takes a :root_layout option to let you specify the root layout for the member LiveViews all at once.

Let's see how we can do it:

live_session :admin, root_layout: {MyAppWeb.LayoutView, "root_admin.html"} do
  live "/users/settings", UserSettingsLive, :edit
end

This way we can customize what we show to our users. For example, we can show one navigation bar for admin users and a different one for regular users.

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It's really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!  

Bonus Example: Set Common Assigns at the Router Level

We can use live_session and :on_mount to set the common assigns for a group of live routes; all of this just in one place!

Setting assigns at the router level is useful to avoid setting assigns on every LiveView or forgetting to do it in some of them. For example, we can define different hooks to set our active menu item:

defmodule Web.MenuAssign do
  @moduledoc """
  Ensures common `assigns` are applied to all LiveViews attaching this hook.
  """
  import Phoenix.LiveView

  def on_mount(:settings, _params, _session, socket) do
    {:cont, assign(socket, :active_item, :settings)}
  end

  def on_mount(:preferences, _params, _session, socket) do
    {:cont, assign(socket, :active_item, :preferences)}
  end
end

Then in the router, we block off a whole section of routes and they will get the :active_item assign set automatically.

  live_session :preferences,
    on_mount: [
      Web.InitAssigns,
      {Web.InitAssigns, :require_current_user},
      {Web.MenuAssign, :preferences}
    ] do

    scope "/preferences", as: :preferences do
      live "/avatar", AvatarPreferenceLive.Index, :index
      live "/notifications", NotificationsPreferenceLive.Index, :new
    end
  end

All routes within the live_session :preferences have set the assign :active_item with the value :preferences

Wrap Up

live_session by itself gives us faster navigation between live routes, but it gains super powers and becomes doubly useful in combination with the on_mount callback.

We can use these features to mark boundaries between routes that look or behave differently. In the first case, we can define a specific root layout for a group of LiveViews; in the second one, we can use hooks to modify the behavior of a session's routes.