phoenix_live_controller v0.4.1 Phoenix.LiveController behaviour View Source

Controller-style abstraction for building multi-action live views on top of Phoenix.LiveView.

Phoenix.LiveView API differs from Phoenix.Controller API in order to emphasize stateful lifecycle of live views, support long-lived processes behind them and accommodate their much looser ties with the router. Contrary to HTTP requests that are rendered and discarded, live actions are mounted and their processes stay alive to handle events & miscellaneous process interactions and to re-render as many times as necessary. Because of these extra complexities, the library drives developers towards single live view per router action.

At the same time, Phoenix.LiveView provides a complete solution for router-aware live navigation and it introduces the concept of live actions both in routing and in the live socket. These features mean that many live views may play a role similar to classic controllers.

It's all about efficient code organization - just like a complex live view's code may need to be broken into multiple modules or live components, a bunch of simple live actions centered around similar topic or resource may be best organized into a single live view module, keeping the related web logic together and giving the room to share common code. That's where Phoenix.LiveController comes in: to organize live view code that covers multiple live actions in a fashion similar to how Phoenix controllers organize multiple HTTP actions. It provides a pragmatic convention that still keeps pieces of a stateful picture visible by enforcing clear function annotations.

Here's an exact live equivalent of an HTML controller generated with the mix phx.gen.html Blog Article articles ... scaffold, powered by Phoenix.LiveController:

# lib/my_app_web.ex
defmodule MyAppWeb do
  def live_controller do
    quote do
      use Phoenix.LiveController
      alias MyAppWeb.Router.Helpers, as: Routes
    end
  end
end

# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
  scope "/", MyAppWeb do
    live "/articles", ArticleLive, :index
    live "/articles/new", ArticleLive, :new
    live "/articles/:id", ArticleLive, :show
    live "/articles/:id/edit", ArticleLive, :edit
  end
end

# lib/my_app_web/live/article_live.ex
defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  alias MyApp.Blog
  alias MyApp.Blog.Article

  @action_handler true
  def index(socket, _params) do
    articles = Blog.list_articles()
    assign(socket, articles: articles)
  end

  @action_handler true
  def new(socket, _params) do
    changeset = Blog.change_article(%Article{})
    assign(socket, changeset: changeset)
  end

  @event_handler true
  def create(socket, %{"article" => article_params}) do
    case Blog.create_article(article_params) do
      {:ok, article} ->
        socket
        |> put_flash(:info, "Article created successfully.")
        |> push_redirect(to: Routes.article_path(socket, :show, article))

      {:error, %Ecto.Changeset{} = changeset} ->
        assign(socket, changeset: changeset)
    end
  end

  @action_handler true
  def show(socket, %{"id" => id}) do
    article = Blog.get_article!(id)
    assign(socket, article: article)
  end

  @action_handler true
  def edit(socket, %{"id" => id}) do
    article = Blog.get_article!(id)
    changeset = Blog.change_article(article)
    assign(socket, article: article, changeset: changeset)
  end

  @event_handler true
  def update(socket, %{"article" => article_params}) do
    article = socket.assigns.article

    case Blog.update_article(article, article_params) do
      {:ok, article} ->
        socket
        |> put_flash(:info, "Article updated successfully.")
        |> push_redirect(to: Routes.article_path(socket, :show, article))

      {:error, %Ecto.Changeset{} = changeset} ->
        assign(socket, article: article, changeset: changeset)
    end
  end

  @event_handler true
  def delete(socket, %{"id" => id}) do
    article = Blog.get_article!(id)
    {:ok, _article} = Blog.delete_article(article)

    socket
    |> put_flash(:info, "Article deleted successfully.")
    |> push_redirect(to: Routes.article_path(socket, :index))
  end
end

Phoenix.LiveController is not meant to be a replacement of Phoenix.LiveView - although most live views may be represented with it, it will likely prove beneficial only for specific kinds of live views. These include live views with following traits:

  • Orientation around same resource, e.g. web code for specific context like in mix phx.gen.html
  • Mounting or event handling code that's mostly action-specific
  • Param handling code that's action-specific and prevails over global mounting code
  • Common redirecting logic executed before mounting or event handling, e.g. auth logic

Mounting actions

Action handlers replace Phoenix.LiveView.mount/3 entry point in order to split mounting of specific live actions into separate functions. They are annotated with @action_handler true and, just like with Phoenix controller actions, their name is the name of the action they mount.

# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
  scope "/", MyAppWeb do
    live "/articles", ArticleLive, :index
    live "/articles/:id", ArticleLive, :show
  end
end

# lib/my_app_web/live/article_live.ex
defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  @action_handler true
  def index(socket, _params) do
    articles = Blog.list_articles()
    assign(socket, articles: articles)
  end

  @action_handler true
  def show(socket, %{"id" => id}) do
    article = Blog.get_article!(id)
    assign(socket, article: article)
  end
end

Note that action handlers don't have to wrap the resulting socket in the {:ok, socket} tuple, which also brings them closer to Phoenix controller actions.

Handling events

Event handlers replace Phoenix.LiveView.handle_event/3 callbacks in order to make the event handling code consistent with the action handling code. These functions are annotated with @event_handler true and their name is the name of the event they handle.

# lib/my_app_web/templates/article/*.html.leex
<%= link "Delete", to: "#", phx_click: :delete, phx_value_id: article.id, data: [confirm: "Are you sure?"] %>

# lib/my_app_web/live/article_live.ex
defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  @event_handler true
  def delete(socket, %{"id" => id}) do
    article = Blog.get_article!(id)
    {:ok, _article} = Blog.delete_article(article)

    socket
    |> put_flash(:info, "Article deleted successfully.")
    |> push_redirect(to: Routes.article_path(socket, :index))
  end
end

Note that, consistently with action handlers, event handlers don't have to wrap the resulting socket in the {:noreply, socket} tuple.

Also note that, as a security measure, LiveController won't convert binary names of events that don't have corresponding event handlers into atoms that wouldn't be garbage collected.

Handling process messages

Message handlers offer an alternative (but not a replacement) to Phoenix.LiveView.handle_info/2 for handling process messages in a fashion consistent with action and event handlers. These functions are annotated with @message_handler true and their name equals to a message atom (e.g. :refresh_article) or to an atom placed as first element in a message tuple (e.g. {:article_update, ...}).

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  @action_handler true
  def show(socket, %{"id" => id}) do
    :timer.send_interval(5_000, self(), :refresh_article)
    assign(socket, article: Blog.get_article!(id))
  end

  @message_handler true
  def refresh_article(socket, _message) do
    assign(socket, article: Blog.get_article!(socket.assigns.article.id))
  end
end

Support for handling messages wrapped in tuples allows to incorporate Phoenix.PubSub in live controllers in effortless and consistent way.

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller
  alias Phoenix.PubSub

  @action_handler true
  def show(socket, %{"id" => id}) do
    article = Blog.get_article!(id)
    PubSub.subscribe(MyApp.PubSub, "article:#{article.id}")
    assign(socket, article: Blog.get_article!(id))
  end

  @message_handler true
  def article_update(socket, {_, article}) do
    assign(socket, article: article)
  end

  @event_handler true
  def update(socket = %{assigns: %{article: article}}, %{"article" => article_params}) do
    article = socket.assigns.article

    case Blog.update_article(article, article_params) do
      {:ok, article} ->
        PubSub.broadcast(MyApp.PubSub, "article:#{article.id}", {:article_update, article})

        socket
        |> put_flash(:info, "Article updated successfully.")
        |> push_redirect(to: Routes.article_path(socket, :show, article))

      {:error, %Ecto.Changeset{} = changeset} ->
        assign(socket, article: article, changeset: changeset)
    end
  end

For messages that can't be handled by message handlers, a specific implementation of Phoenix.LiveView.handle_info/3 may still be provided.

Note that, consistently with action & event handlers, message handlers don't have to wrap the resulting socket in the {:noreply, socket} tuple.

Applying session

Session, previously passed to Phoenix.LiveView.mount/3, is not passed through to action handlers. Instead, an optional apply_session/2 callback may be defined in order to read the session and modify socket before an actual action handler is called.

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  @impl true
  def apply_session(socket, session) do
    user_token = session["user_token"]
    user = user_token && Accounts.get_user_by_session_token(user_token)

    assign(socket, current_user: user)
  end

  # ...
end

Note that, in a fashion similar to controller plugs, no further action handling logic will be called if the returned socket was redirected - more on that below.

Updating params without redirect

For live views that implement parameter patching (e.g. to avoid re-mounting the live view & resetting its DOM or state), action handlers also replace Phoenix.LiveView.handle_params/3 callbacks. The same action handler is called once when mounting and then it's called again whenever params are patched.

This means that parameter patching is supported out-of-the-box for action handlers that work just as fine for initial mount as for subsequent parameter changes.

# lib/my_app_web/templates/article/index.html.leex
<%= live_patch "Page 2", to: Routes.article_path(@socket, :index, page: "2") %>

# lib/my_app_web/live/article_live.ex
defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  @action_handler true
  def index(socket, params) do
    articles = Blog.list_articles(page: params["page"])
    assign(socket, articles: articles)
  end
end

Using the mounted?/1 helper, action handlers may conditionally invoke parts of their logic depending on whether socket was already mounted, e.g. to initiate timers or run expensive loads that don't depend on params only upon the first mount.

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  @action_handler true
  def index(socket, params) do
    if connected?(socket) && !mounted?(socket),
      do: :timer.send_interval(5_000, self(), :check_for_new_articles)

    socket = unless mounted?(socket),
      do: assign(socket, tags: Blog.list_tags()),
      else: socket

    articles = Blog.list_articles(page: params["page"])
    assign(socket, articles: articles)
  end
end

Note that an action handler will only be called once when mounting, even though native LiveView calls both mount/3 and handle_params/3 at that moment.

Chaining & plugs

Phoenix controllers are backed by the power of Plug pipelines in order to organize common code called before actions and to allow halting early. LiveController provides similar solution for these problems via plug/2 macro supported by the chain/2 helper function.

plug/2 allows to define callbacks that are called in a chain in order to act on a socket before an actual action, event or message handler is called:

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  plug :require_authenticated_user

  defp require_authenticated_user(socket = %{assigns: %{current_user: user}}) do
    if user do
      socket
    else
      socket
      |> put_flash(:error, "You must log in first.")
      |> push_redirect(to: "/")
    end
  end
end

It's possible to scope given plug to only a subset of handlers:

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  plug :require_authenticated_user when action not in [:index, :show]
end

The when condition is evaluated at compile-time with action, event and message variables made available for sake of filtering. Depending on the context in which the plug is called, one of them includes the handler name and remaining ones are nil.

It's also possible to call the plug with arbitrary options:

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  plug :require_user_role, :admin

  defp require_user_role(socket = %{assigns: %{current_user: user}}, required_role) do
    if user.role == required_role do
      socket
    else
      socket
      |> put_flash(:error, "You must be #{required_role} in order to continue.")
      |> push_redirect(to: "/")
    end
  end
end

Following variables may be referenced when specifying the options:

  • action / event / message - action, event or message handler name (atom or nil)
  • params - action or event params (map or nil)
  • payload - message payload (atom/tuple or nil)

For example:

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  plug :fetch_article_for_change, params when action in [:edit] or event in [:update, :delete]

  defp fetch_article_for_change(
    socket = %{assigns: %{current_user: %{id: user_id}}},
    %{"id" => article_id}
  ) do
    case Blog.get_article!(id) do
      article = %{author_id: ^user_id} ->
        assign(socket, :article, article)

      _ ->
        socket
        |> put_flash(:error, "You can't modify someone else's article.")
        |> push_redirect(to: "/")
    end
  end
end

Finally, plugs may be defined in separate modules, either with call callback (in which case you may use the Phoenix.LiveController.Plug behaviour) or with specific callback function name:

defmodule MyAppWeb.Authorize do
  @behaviour Phoenix.LiveController.Plug

  @impl true
  def call(socket = %{assigns: %{current_user: user}}, required_role) do
    if user.role == role do
      socket
    else
      socket
      |> put_flash(:error, "You must be #{required_role} in order to continue.")
      |> push_redirect(to: "/")
    end
  end
end

defmodule MyAppWeb.UserAuth do
  defp require_authenticated_user(socket = %{assigns: %{current_user: user}}, _payload) do
    if user do
      socket
    else
      socket
      |> put_flash(:error, "You must log in first.")
      |> push_redirect(to: "/")
    end
  end
end

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller
  alias MyAppWeb.{Authorize, UserAuth}

  plug {UserAuth, :require_authenticated_user}
  plug Authorize, :admin
end

If multiple plugs are defined like above, they'll be called in a chain. If any of them redirects the socket or returns a tuple instead of just socket then the chain will be halted, which will also prevent action, event or message handler from being called.

This is guaranteed by internal use of the chain/2 function. This simple helper calls any function that takes socket as argument & that returns it only if the socket wasn't previously redirected or wrapped in a tuple and passes the socket through otherwise. It may also be used inside a plug or handler code for a similar result:

defmodule MyAppWeb.ArticleLive do
  use MyAppWeb, :live_controller

  @action_handler true
  def edit(socket, %{"id" => id}) do
    socket
    |> require_authenticated_user()
    |> chain(&assign(&1, article: Blog.get_article!(id)))
    |> chain(&authorize_article_author(&1, &1.assigns.article))
    |> chain(&assign(&1, changeset: Blog.change_article(&.assigns.article)))
  end
end

After all plugs are called without halting the chain, action_handler/3, event_handler/3 and message_handler/3 - rough equivalents of action/2 plug in Phoenix controllers - complete the pipeline by calling functions named after specific actions, events or messages.

Specifying LiveView options

Any options that were previously passed to use Phoenix.LiveView, such as :layout or :container, may now be passed to use Phoenix.LiveController.

use Phoenix.LiveController, layout: {MyAppWeb.LayoutView, "live.html"}

Rendering actions

Implementation of the Phoenix.LiveView.render/1 callback may be omitted in which case the default implementation will be injected. It'll ask the view module named after specific live module to render HTML template named after the action - the same way that Phoenix controllers do when the Phoenix.Controller.render/2 is called without a template name.

For example, MyAppWeb.ArticleLive mounted with :index action will render with following call:

MyAppWeb.ArticleView.render("index.html", assigns)

Custom Phoenix.LiveView.render/1 implementation may still be provided if necessary.

Link to this section Summary

Functions

Calls given function if socket wasn't redirected, passes the socket through otherwise.

Returns true if the socket was previously mounted by action handler.

Define a callback that acts on a socket before action, event or essage handler.

Callbacks

Invokes action handler for specific action.

Allows to read the session and modify socket before an actual action handler is called.

Invokes event handler for specific event.

Invokes message handler for specific message.

Link to this section Functions

Calls given function if socket wasn't redirected, passes the socket through otherwise.

Read more about the role that this function plays in the live controller pipeline in docs for Phoenix.LiveController.

Returns true if the socket was previously mounted by action handler.

Read more about the role that this function plays when implementing action handlers in docs for Phoenix.LiveController.

Link to this macro

plug(target, opts \\ nil)

View Source (macro)

Define a callback that acts on a socket before action, event or essage handler.

Read more about the role that this macro plays in the live controller pipeline in docs for Phoenix.LiveController.

Link to this section Callbacks

Link to this callback

action_handler(socket, name, params)

View Source (optional)

Invokes action handler for specific action.

It can be overridden, e.g. in order to modify the list of arguments passed to action handlers.

@impl true
def action_handler(socket, name, params) do
  apply(__MODULE__, name, [socket, params, socket.assigns.current_user])
end

It can be wrapped, e.g. for sake of logging or modifying the socket returned from action handlers.

@impl true
def action_handler(socket, name, params) do
  Logger.debug("#{__MODULE__} started handling #{name}")
  socket = super(socket, name, params)
  Logger.debug("#{__MODULE__} finished handling #{name}")
  socket
end

Read more about the role that this callback plays in the live controller pipeline in docs for Phoenix.LiveController.

Link to this callback

apply_session(socket, session)

View Source (optional)
apply_session(socket :: Phoenix.LiveView.Socket.t(), session :: map()) ::
  Phoenix.LiveView.Socket.t()

Allows to read the session and modify socket before an actual action handler is called.

Read more about how to apply the session and the consequences of returning redirected socket from this callback in docs for Phoenix.LiveController.

Link to this callback

event_handler(socket, name, params)

View Source (optional)

Invokes event handler for specific event.

It works in a analogous way and opens analogous possibilities to action_handler/3.

Read more about the role that this callback plays in the live controller pipeline in docs for Phoenix.LiveController.

Link to this callback

message_handler(socket, name, message)

View Source (optional)
message_handler(
  socket :: Phoenix.LiveView.Socket.t(),
  name :: atom(),
  message :: any()
) :: Phoenix.LiveView.Socket.t() | {:noreply, Phoenix.LiveView.Socket.t()}

Invokes message handler for specific message.

It works in a analogous way and opens analogous possibilities to action_handler/3.

Read more about the role that this callback plays in the live controller pipeline in docs for Phoenix.LiveController.