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:
However, keep in mind that live navigation between LiveViews of different sessions using live_redirect
is not possible since it will be necessary to do a full page reload, going through the plug pipeline.
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:
You can also attach a specific hook to a particular LiveView, by calling it at the beginning of the LiveView definition.
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.
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.