Phoenix LiveView Tutorial

Phoenix LiveView Tutorial - build interactive live chat application from scratch

Elixir and its ecosystem is gradually becoming the go-to platform for many web application developers who want both performance and productivity without a tradeoff. With a thorough crash course of Phoenix Framework's hot deal, Phoenix LiveView, using which we'll build a Messenger-like live chat application, we'll try to demonstrate that with Elixir it's easy to write real-time messaging apps that leverage the lanugage's concurrency capabilities.

Table of contents

    Note from 2024: This Phoenix LiveView tutorial was updated as of November 2020 to match then-latest Phoenix LiveView 0.14.8. As of now, we are working on updating to match the current version's standards.

    If you're seeking a detailed introductory overview of Phoenix LiveView instead of an application tutorial, read our post explaining its paradigm and way of work, comparing it against other technologies, and outlining its pros and cons.

    Basic information about Elixir, Phoenix and LiveView

    Elixir, built on top of Erlang/OTP, offers a mix of making life easy and being a scalable and reliable platform that works well with a traffic of 4000 users as well as of 4,000,000 users.

    Phoenix Framework, consistently ranking as one of the most-loved by developers, is Elixir's tool that revolves around a MVC-like structure with a few modules that make it stand out:

    • Phoenix Channels facilitate real-time communication with WebSockets, using Elixir's lightweight processes to maintain connections and state,
    • Phoenix PubSub helps communicate between Channel or LiveView processes with each other as well as subscribe to events from other Elixir processes,
    • Phoenix Presence provides tracking to processes and channels,
    • Phoenix LiveView lets create reactive UIs with little to no additional JS code, pushing DOM diffs to clients and maintaining state via Phoenix Channels.

    Using LiveView helps us turn Elixir specialists into full-stack developers, especially as they don't necessarily love JavaScript and the consistent usage of Elixir helps them be at their best. Read our Phoenix Framework guide to learn more about Phoenix in general.

    Lessons learnt from reactive UI libraries

    Many JavaScript frameworks, both contemporary and not-so-contemporary ones, rely on manipulating the page's DOM for dynamic content updates.

    Historically, for instance, developers using BackboneJS would define a Backbone.View to represent an atomic chunk of user interface, behind which there's a Backbone.Model, encapsulating the business logic of data.

    Backbone remained unopinionated about how views were to be rendered, so it had no built-in tools to make the re-rendering of views on model changes efficient - the whole structure of a view had to be built from scratch and replaced, which tended to yield inefficient views.

    In contrast, modern frameworks such as ReactJS or Vue.js don't care about how the data model layer works at all (loosely coupled data stores such as Redux are often used for this) - but they have a virtual DOM concept - long story short, a pattern of incrementally upgrading only those elements that need to be changed, based on changes in the state of particular components and their children.

    The challenge, though, is pretty much down to how to exchange data between the UI and the backend. You will usually need to implement a JSON API or a GraphQL service, or perhaps you could develop a WebSocket-based solution using Phoenix Channels.

    Either way, the Pareto 80/20 principle will imminently catch you, and when you get to the 20% of work needed to finish off your message-passing code, it'll soon become a framework within a framework.

    Phoenix LiveView: groundbreaking yet familiar

    It is familiar in that it lets you define UI elements as nestable components composed of pure HTML markup, and it builds upon the experience of reactive UI frameworks in implementing mechanisms that calculate diffs between consecutive UI states to ensure efficient updates.

    It is groundbreaking in the way it maintains the states of components and manages their updates in Elixir processes, capable of running in millions - in Phoenix LiveView, components are stateful on the server, and their events and updates are communicated via a bidirectional WebSocket connection via Phoenix Channels, each of which is a GenServer-like BEAM process.

    Rendering content via JavaScript, e.g. via React, often results in indexing and SEO issues which requires trickery. In Phoenix LiveView, the initial render is static as in the classic HTML request-response cycle, so you'll get good Lighthouse scores and it won't hurt your SEO. Also, most use cases don't need JS code at all - no JS event handlers and manual element manipulation anymore, and no need for new controller actions and routes on the backend.

    Phoenix LiveView basic usage

    The basic idea behind Phoenix LiveView is very simple and straightforward.

    LiveView is an Elixir behaviour, and your most basic LiveView definition will consist of two callback implementations:

    • A render/1 function, containing the template of how your component is represented in HTML, with elements of the component's state interpolated. This is much like defining an ordinary view. The special ~L sigil is used to interpolate assigns into your EEx syntax, and convert it into an HTML-safe structure.
    • A mount/2 function, wiring up socket assigns and establishing the LiveView's initial state.
      defmodule YourappWeb.CounterLive do
        use Phoenix.LiveView
    
        def render(assigns) do
          ~L"""
          <a href='#' phx-click='increment'>
            I was clicked <%= @counter %> times!
          </a>
          """
        end
    
        def mount(params, socket) do
          {:ok, assign(socket, :counter, 0)}
        end
      end

    However, the whole fun of using LiveView is managing its state, and the next two callbacks will come in handy.

    handle_event/3 callback

    A handle_event/3 function, handling events coming from the browser. Noticed the phx-click attribute in our template's link? This is the name of an event that will be transported to the LiveView process via WebSockets. We'll define a function clause that will match to the event's name.

      def handle_event("increment", params, %{assigns: %{counter: counter}} = socket) do
        {:noreply, assign(socket, :counter, counter + 1)}
      end

    It will mutate the LiveView's state to have a new, incremented value of the counter, and the render/1 function will be called with the new assigns.

    The second argument, here named params, is of special interest as well, because - in the case of a phx-click event - it contains the event's metadata:

      %{
        "altKey" => false,
        "ctrlKey" => false,
        "metaKey" => false,
        "pageX" => 399,
        "pageY" => 197,
        "screenX" => 399,
        "screenY" => 558,
        "shiftKey" => false,
        "x" => 399,
        "y" => 197
      }

    Similarly, with a <form> tag, the phx-change event can be used to serialize form data into the event that can be parsed on the server.

    handle_info/2 callback

    A handle_info/2 callback, handling events coming from anywhere but the browser. This means events sent from external processes as opposed to events from the browser (remember a LiveView is just an Elixir process, so you can do whatever's needed in order for it to receive messages!), or events sent from the LiveView to itself. For instance, it takes this to increment the counter every 5 seconds:

      def mount(params, socket) do
        if connected?(socket), do: :timer.send_interval(5000, self(), :increment)
    
        {:ok, assign(socket, :counter, 0)}
      end
    
      def handle_info(:increment, %{assigns: %{counter: counter}} = socket) do
        {:noreply, socket |> assign(:counter, counter + 1)}
      end

    To reduce code repetition, you could make handle_event/3 send a message to self() that triggers the same handle_info/2 routine.

    You can now access your LiveView as a standalone route - to do this, put this in your router.ex:

    import Phoenix.LiveView.Router
    
    scope "/", YourappWeb do
     live "/counter", CounterLive
    end

    ...or render the LiveView within any other template:

    <%= Phoenix.LiveView.live_render(@conn, YourappWeb.CounterLive) %>

    We'll prepare our app for Phoenix LiveView and install all needed dependencies, design the app's Ecto schemas, related contexts, and database structure, to accommodate for the app's business logic.

    Initial steps: install the tools, create the project

    If these are your very first steps in Phoenix Framework, please install the framework's bootstrapping scripts - Phoenix's Hex documentation will be helpful for you. Elixir and Erlang, as well as NodeJS, need to be installed, and we recommend the asdf-vm extensible version manager for all of these tools.

    Phoenix's default DB is PostgreSQL, and we'll be storing all of the app's data in a Postgres DB - make sure it's installed as well.

    Now create Phoenix's basic project structure using the installed script. We recommend tracking changes with Git, too.

    mix phx.new curious_messenger
    cd curious_messenger
    git init .

    Database configuration

    Phoenix creates a basic database configuration for all environments in config/dev.exs, config/test.exs and config/prod.exs. Database credentials shouldn't be shared in repositories - for production it would be a security concern, while for dev and test it's just annoying to your collaborators because everyone's got a slightly different setup.

    As a good practice, for improved security, put import_config "dev.secret.exs" and import_config "test.secret.exs" at the end of dev.exs and test.exs, respectively, and create dev.secret.exs and test.secret.exs files while also ignoring them in source control by adding to .gitignore.

    # dev.secret.exs
    
    use Mix.Config
    
    # Configure your database
    config :curious_messenger, CuriousMessenger.Repo,
      username: "postgres",
      password: "postgres",
      database: "curious_messenger_dev",
      hostname: "localhost",
      show_sensitive_data_on_connection_error: true,
      pool_size: 10
    
    # test.secret.exs
    
    use Mix.Config
    
    # Configure your database
    config :curious_messenger, CuriousMessenger.Repo,
      username: "postgres",
      password: "postgres",
      database: "curious_messenger_test",
      hostname: "localhost",
      pool: Ecto.Adapters.SQL.Sandbox

    You can make copies of those files (with your credentials blanked out), intended to be tracked in source control, with .sample appended to their names if you'd like to keep things simple for those who clone your repository.

    Make an initial Git commit at this point to maintain a healthy habit of frequent committing.

    Then, let Ecto create a new database according to the Postgres configs:

    mix ecto.create

    Installing LiveView

    Phoenix LiveView is not a default dependency in Phoenix, so we need to add it to your project's mix.exs file, after which mix deps.get needs to be executed.

    defp deps do
    [
      # ...,
      {:phoenix_live_view, "~> 0.14.8"}
    ]
    end

    A few configuration steps need to be taken now. We need to configure a signing salt, which is a mechanism that prevents man-in-the-middle attacks.

    A secret value can be securely generated using:

    mix phx.gen.secret 32

    Then, paste it into config/config.exs:

    config :curious_messenger, CuriousMessengerWeb.Endpoint,
      #...,
      live_view: [
        signing_salt: "pasted_salt"
      ]

    We need to ensure that LiveView can fetch flash messages - which is a mechanism typically used to present messages after HTTP redirects. For this to work, let's add to router.ex around the current flash plug declaration. Let's also add a root layout declaration for the app.

    defmodule CuriousMessengerWeb.Router do
      pipeline :browser do
        # ...
    
        plug :fetch_flash
        plug :fetch_live_flash # add this between "non-live" flash and forgery protection
        plug :protect_from_forgery
    
        # ...
    
        # add this add the end of the :browser pipeline
        plug :put_root_layout, {CuriousMessengerWeb.LayoutView, :root}
      end
    end

    The purpose of using the :put_root_layout plug is to ensure that LiveView layouts and plain Phoenix layouts use a common template as their basis.

    Rename lib/curious_messenger_web/templates/layout/app.html.eex to root.html.eex and remove the two lines in the <main> tag:

    <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
    <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>

    so that the <main> tag looks like this:

    <main role="main" class="container">
      <%= @inner_content %>
    </main>

    Let's now create a new lib/curious_messenger_web/templates/layout/app.html.eex file just as the following - omitting all other lines:

    <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
    <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
    <%= @inner_content %>

    Then, create a live.html.leex template in the same folder (note the .leex extension that ensures LiveView rendering is used) with the following content. Notice that we're using live_flash helper instead of get_flash so in this case flash is retrieved from LiveView state and not the standard HTTP session.

    <p class="alert alert-info" role="alert"><%= live_flash(@flash, :notice) %></p>
    <p class="alert alert-danger" role="alert"><%= live_flash(@flash, :error) %></p>
    <%= @inner_content %>

    Our common codebase for controllers, views and the router - living in lib/curious_messenger_web.ex - needs to include LiveView-related functions from Phoenix.LiveView.Controller, Phoenix.LiveView and Phoenix.LiveView.Router.

    def controller do
      quote do
        # ...
        import Phoenix.LiveView.Controller
      end
    end
    
    def view do
      quote do
        # ...
        import Phoenix.LiveView, only: [live_render: 2, live_render: 3, live_link: 1, live_link: 2]
      end
    end
    
    def router do
      quote do
        # ...
        import Phoenix.LiveView.Router
      end
    end

    To read up on quote, read our Elixir Trickery: Using Macros & Metaprogramming Without Superpowers article.

    Since Phoenix LiveView is based on WebSockets, a bidirectional protocol for full-duplex communication, which is very different from HTTP, in endpoint.ex you need to add the following to declare that /live is the path used for establishing WebSocket connection between Phoenix server and the browser.

    defmodule CuriousMessengerWeb.Endpoint do
      use Phoenix.Endpoint, otp_app: :curious_messenger
    
      socket "/live", Phoenix.LiveView.Socket
      # ...

    LiveView will let us code reactive UIs with virtually no JavaScript code. This, however, comes at a price of having to include a JS bundle that will take care of handling the WebSocket connection, sending and receiving messages, and updating the HTML DOM.

    Our experience tells us that it's not detrimental to page performance, so you shouldn't probably worry about it too much in the context of SEO and performance audits.

    Add the following to assets/package.json:

    "dependencies": {
      "phoenix_live_view": "file:../deps/phoenix_live_view"
    }

    Run npm install in the assets folder, and then append this to app.js to initialize LiveView's WebSocket connection:

    import { Socket } from "phoenix"
    import LiveSocket from "phoenix_live_view"
    
    let liveSocket = new LiveSocket("/live", Socket)
    liveSocket.connect()

    Commit changes and proceed to creating a first live view module.

    Designing and implementing the app's business domain

    Analyze requirements, define contexts and Ecto schemas

    Let's dig into the business model we want to support and design the requirements.

    We want to store users communicating messages between them. Each message is part of a conversation, which is associated with two or more users, with a message always having a specified sender. Messages can be marked as seen at a specific point when displayed by recipient.

    As in most modern instant messaging apps, we want a "Message Seen" feature that tracks which conversation members have seen a message, in which every information about who's seen a message has a specific timestamp.

    Phoenix Contexts

    Phoenix promotes the concept of contexts to organize your business logic code and encapsulate the data access layer. This Phoenix LiveView tutorial will use a simplified structure, for insight on how we usually structure contexts at Curiosum, read our article on Context guidelines.

    We'll have our context modules talk to the Ecto repository, and Phoenix controllers will only talk to domain functions in the appropriate context modules, which will help us keep code clean and organized.

    Each context will hold one or more Ecto schemas serving as data mappers for our tables - based on our functional requirements, here's an outline of what structure we'll use:

    • An Auth context, containing the User schema. We'll keep this schema very basic for now, only containing the user's nickname, and augment it later when we get to integrate Pow for user authentication.

    • A Chat context, containing the following schemas:

      • Conversation, with a title, identifying a conversation.
      • ConversationMember, related to a conversation and a user, serving as a registration of a user within a conversation. For each conversation, one user can be its owner, who'll be able to e.g. close it.
      • Message, belonging to a conversation and a user who sent it, having a content.
      • SeenMessage, belonging to a user and a message, whose created_at timestamp denotes when the user first displayed the message.

    Let's use the handy phx.gen.context generator to automatically generate Ecto schemas, database migrations and CRUD functions for each schema.

    mix phx.gen.context Auth User auth_users \
      nickname:string
    
    mix phx.gen.context Chat Conversation chat_conversations \
      title:string
    
    mix phx.gen.context Chat ConversationMember chat_conversation_members \
      conversation_id:references:chat_conversations \
      user_id:references:auth_users \
      owner:boolean
    
    mix phx.gen.context Chat Message chat_messages \
      conversation_id:references:chat_conversations \
      user_id:references:auth_users \
      content:text
    
    mix phx.gen.context Chat SeenMessage chat_seen_messages \
      user_id:references:auth_users \
      message_id:references:chat_messages
    

    This generates the CuriousMessenger.Auth and CuriousMessenger.Chat contexts with the Auth context, for instance, having list_auth_users, get_user!, create_user, update_user and delete_user functions.

    Respective Ecto schemas live in CuriousMessenger.Auth.User, CuriousMessenger.Chat.Conversation, etc., and have their fields automatically defined. Notice that we've prefixed all table names with the context name, e.g. auth_users for CuriousMessenger.Auth.User. We need some schema changes, though, as well as modifications to generated migrations.

    For the auth_users migration, we want a unique index on nicknames, as well as a non-null constraint, so look up the migration file with create_auth_users in name and make those changes:

    # Modify line in the "create table" block:
    add :nickname, :string, null: false
    
    # Append at the end of `change` function:
    create unique_index(:auth_users, [:nickname])

    And modify the User schema accordingly in user.ex:

    # Validate nickname presence and uniqueness:
    def changeset(user, attrs) do
      user
      |> cast(attrs, [:nickname])
      |> validate_required([:nickname])
      |> unique_constraint(:nickname)
    end

    In the chat_conversations migration, let's ensure that its title is present:

    add :title, :string, null: false

    Let's reflect this in the Chat.Conversation schema, and define the relationship between Conversation and ConversationMember and Message:

    alias CuriousMessenger.Chat.{ConversationMember, Message}
    
    schema "chat_conversations" do
      field :title, :string
    
      has_many :conversation_members, ConversationMember
      has_many :messages, Message
    
      timestamps()
    end
    
    @doc false
    def changeset(conversation, attrs) do
      conversation
      |> cast(attrs, [:title])
      |> validate_required([:title])
    end

    Let's now link Conversation to User using ConversationMember. Open up the migration - here's how it should look like. Notice that we've added not-null constraints to conversation_id and user_id, and we've created two interesting unique indexes.

    def change do
      create table(:chat_conversation_members) do
        add :owner, :boolean, default: false, null: false
        add :conversation_id, references(:chat_conversations, on_delete: :nothing), null: false
        add :user_id, references(:auth_users, on_delete: :nothing), null: false
    
        timestamps()
      end
    
      create index(:chat_conversation_members, [:conversation_id])
      create index(:chat_conversation_members, [:user_id])
      create unique_index(:chat_conversation_members, [:conversation_id, :user_id])
    
      create unique_index(:chat_conversation_members, [:conversation_id],
             where: "owner = TRUE",
             name: "chat_conversation_members_owner"
           )
    end

    The first unique index ensures that one user can be associated with each conversation only once, which is logical. The second one is a PostgreSQL partial index only created on the table's records with owner set to true, which means that only one conversation member record with a given conversation_id will ever be the conversation's owner.

    Now we need to reflect the not-null constraints in the schema, as well as using the unique constraints.

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.Conversation
    
    schema "chat_conversation_members" do
      field :owner, :boolean, default: false
    
      belongs_to :user, User
      belongs_to :conversation, Conversation
    
      timestamps()
    end
    
    @doc false
    def changeset(conversation_member, attrs) do
      conversation_member
      |> cast(attrs, [:owner, :conversation_id, :user_id])
      |> validate_required([:owner, :conversation_id, :user_id])
      |> unique_constraint(:user, name: :chat_conversation_members_conversation_id_user_id_index)
      |> unique_constraint(:conversation_id,
        name: :chat_conversation_members_owner
      )
    end

    Note that we specified the names of unique constraints, beacuse these indexes are on multiple columns - the first one was automatically generated by Ecto, the second one was a name of our choice, describing the purpose of that index - related to the conversation owner.

    For Message, let's change the migration to define not-null constraints:

    add :conversation_id, references(:chat_conversations, on_delete: :nothing), null: false
    add :user_id, references(:auth_users, on_delete: :nothing), null: false

    And have the schema define relationship definitions - a message belongs to a conversation and a user, and has many seen message records.

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.{Conversation, SeenMessage}
    
    schema "chat_messages" do
      field :content, :string
    
      belongs_to :conversation, Conversation
      belongs_to :user, User
    
      has_many :seen_messages, SeenMessage
    
      timestamps()
    end
    
    @doc false
    def changeset(message, attrs) do
      message
      |> cast(attrs, [:content, :conversation_id, :user_id])
      |> validate_required([:content, :conversation_id, :user_id])
    end

    For SeenMessage, let's add not-null constraints and an unique index:

    add :user_id, references(:auth_users, on_delete: :nothing), null: false
    add :message_id, references(:chat_messages, on_delete: :nothing), null: false
    
    # ...
    
    create unique_index(:chat_seen_messages, [:user_id, :message_id])

    Let's also update the schema:

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.Message
    
    schema "chat_seen_messages" do
      belongs_to :user, User
      belongs_to :message, Message
    
      timestamps()
    end
    
    @doc false
    def changeset(seen_message, attrs) do
      seen_message
      |> cast(attrs, [:user_id, :message_id])
      |> validate_required([:user_id, :message_id])
    end

    Now, do mix ecto.migrate to let Ecto create tables defined in migration files.

    We'll come back to contexts later. Now, commit your changes and move on to LiveView installation.

    Your first LiveView

    With the first page, we'll let you get into a conversation between two users, and do a simple message exchange between them.

    For now, we'll just pre-populate user, conversation and conversation membership records using a seeds script file.

    Add the following to priv/repo/seeds.exs:

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.{Conversation, ConversationMember}
    
    alias CuriousMessenger.{Auth, Chat}
    
    {:ok, %User{id: u1_id}} = Auth.create_user(%{nickname: "User One"})
    {:ok, %User{id: u2_id}} = Auth.create_user(%{nickname: "User Two"})
    
    {:ok, %Conversation{id: conv_id}} = Chat.create_conversation(%{title: "Modern Talking"})
    
    {:ok, %ConversationMember{}} =
      Chat.create_conversation_member(%{conversation_id: conv_id, user_id: u1_id, owner: true})
    
    {:ok, %ConversationMember{}} =
      Chat.create_conversation_member(%{conversation_id: conv_id, user_id: u2_id, owner: false})

    Then, run it with:

    mix run priv/repo/seeds.exs

    The database now contains two pre-populated user records and a conversation between them.

    Now add to the scope "/", CuriousMessengerWeb block in router.ex:

    live "/conversations/:conversation_id/users/:user_id", ConversationLive

    This will point this route to the CuriousMessengerWeb.ConversationLive LiveView module that we'll soon create, which will render a live view of a conversation with conversation_id id in the context of the user identified by user_id.

    Now we'll create the lib/curious_messenger_web/live/conversation_live.ex file. Let the initial version contain the skeleton for a couple of Phoenix.LiveView behaviour's callbacks that we'll need to implement.

    defmodule CuriousMessengerWeb.ConversationLive do
      use Phoenix.LiveView
      use Phoenix.HTML
    
      alias CuriousMessenger.{Auth, Chat, Repo}
    
      def render(assigns) do
        ...
      end
    
      def mount(assigns, socket) do
        ...
      end
    
      def handle_event(event, payload, socket) do
        ...
      end
    
      def handle_params(params, uri, socket) do
        ...
      end
    end

    The roles of these callbacks are:

    • mount/2 is the callback that runs right at the beginning of LiveView's lifecycle, wiring up socket assigns necessary for rendering the view. Since we're running a page which needs to load records based on URI params, and mount/2 has no access to those, we'll just keep it trivial:
      def mount(_assigns, socket) do
        {:ok, socket}
      end
    • handle_params/3 runs after mount and this is the stage at which we can read the query params supplied. We won't always do this, because we'll often render a LiveView as part of a larger template, not directly via a defined route; but in this case, we need to use it. Later, it can also intercept parameter changes during your stay on the page, so that it won't have to always instantiate a new LiveView process.
      def handle_params(%{"conversation_id" => conversation_id, "user_id" => user_id}, _uri, socket) do
        {:noreply,
          socket
          |> assign(:user_id, user_id)
          |> assign(:conversation_id, conversation_id)
          |> assign_records()}
      end
    
      # A private helper function to retrieve needed records from the DB
      defp assign_records(%{assigns: %{user_id: user_id, conversation_id: conversation_id}} = socket) do
        user = Auth.get_user!(user_id)
    
        conversation =
          Chat.get_conversation!(conversation_id)
          |> Repo.preload(messages: [:user], conversation_members: [:user])
    
        socket
        |> assign(:user, user)
        |> assign(:conversation, conversation)
        |> assign(:messages, conversation.messages)
      end

    The handle_params/3 function signature has pattern matching on the parameters, ignores the URI and assigns state to the socket behind the LiveView, similarly to how one would do this with a Plug.Conn.

    • render/1 defines the rendered template and uses the socket's assigns to read the current LiveView's state. It uses the ~L sigil to compile a template with assigns, and the template will be re-rendered every time a dynamic portion of it changes (see documentation for Phoenix.LiveView.Engine for more details, because it's a really interesting process).
      We'll assume that, on every render, the page will contain assigns for user, denoting current user, conversation, including data about current conversation, and messages, containing all of the conversation's messages.
      def render(assigns) do
        ~L"""
        <div>
          <b>User name:</b> <%= @user.nickname %>
        </div>
        <div>
          <b>Conversation title:</b> <%= @conversation.title %>
        </div>
        <div>
          <%= f = form_for :message, "#", [phx_submit: "send_message"] %>
            <%= label f, :content %>
            <%= text_input f, :content %>
            <%= submit "Send" %>
          </form>
        </div>
        <div>
          <b>Messages:</b>
          <%= for message <- @messages do %>
            <div>
              <b><%= message.user.nickname %></b>: <%= message.content %>
            </div>
          <% end %>
        </div>
        """
      end
    • handle_event/3, whose role is to process events triggered by code running in the browser. Noticed the phx_submit attribute we applied to the form in render/1? This indicates that, over the WebSocket connection, an event named send_message will be sent. The second argument of handle_event/3 will receive all of the event's metadata, which will allow us to read the value from our form. Then, it'll create a new message record in the database and append it to the socket's assigns, which will cause the LiveView to re-render.
      def handle_event(
            "send_message",
            %{"message" => %{"content" => content}},
            %{assigns: %{conversation_id: conversation_id, user_id: user_id, user: user}} = socket
          ) do
        case Chat.create_message(%{
              conversation_id: conversation_id,
              user_id: user_id,
              content: content
            }) do
          {:ok, new_message} ->
            new_message = %{new_message | user: user}
            updated_messages = socket.assigns[:messages] ++ [new_message]
    
            {:noreply, socket |> assign(:messages, updated_messages)}
    
          {:error, _} ->
            {:noreply, socket}
        end
      end

    The current repository state is tagged in GitHub.

    There you go! Run mix phx.server and navigate to localhost:4000/conversations/1/users/1 in your browser (assuming you've run your seeds.exs file).

    Initial version of Phoenix LiveView-based Messenger app.

    You can send messages to the app, they'll appear on the page, and they'll still be there after you refresh it or restart the server.

    Try opening a separate window and navigating to localhost:4000/conversations/1/users/2. You'll notice that it works too and you can see the messages already sent from the other window.

    For now, it lacks instant updates - you will only see new messages from others after refreshing the page. We'll fix it up in a moment with Phoenix PubSub.

    Live Updates with Phoenix PubSub

    So far, we have this working view of a conversation between two or more users:

    Initial version of Phoenix LiveView-based Messenger app.

    While your "own" LiveView notifies its associated browser of conversation updates (and the page's DOM is then updated by LiveView's script), the issue is that the other LiveView connections that see the conversation are not instantly updated. That's expected, since each user seeing a LiveView is represented by a single Erlang process** that backs a WebSocket connection.

    Each process has a PID, we can store the PIDs of every conversation member's connected LiveViews, and when a new message is sent, broadcast it to all the PIDs - each of the processes would then handle it with the handle_info/2 callback.

    This, however, would be rather troublesome, because we'd have to add schemas to our database or Erlang's native Mnesia to store the PIDs and maintain their cleanup on connection closing.

    Phoenix PubSub is a much better solution. It is Phoenix's realization of the publish-subscribe pattern, in which one agent publishes messages, and others subscribe to get notified about them. It's driven by Erlang's pg2 mechanism or Redis - we'll stick to the former, but Redis could be interesting for persistence.

    This mechanism is used by Phoenix Channels (along with WebSocket connections to the frontend) which also drives LiveView.

    In this case, we'd like to keep the frontend oblivious to whatever happens at the server side that makes the LiveView receive a message and render it into the new DOM fragment that's to be rendered by the browser. We'll make direct usage of PubSub's API to make respective users' LiveViews to notify each other about incoming messages.

    Each LiveView that views a conversation will subscribe to the conversation's topic. Whenever someone sends a message, they'll broadcast a new_message event to that topic, to which other processes will react by updating state via handle_info, which will result in running the render callback.

    PubSub implementation for Curious Messenger

    As we said, LiveView uses Phoenix Channels which use the PubSub module under the hood anyway - so there are no new dependencies to add. There's a small config change we need to make in config/config.exs:

    config :curious_messenger, CuriousMessengerWeb.Endpoint,
      # ...
      pubsub_server: CuriousMessenger.PubSub,
      # ...

    You also need to add in the start function in lib/curious_messenger/application.ex:

    def start(_type, _args) do
      children = [
        # ...,
        {Phoenix.PubSub, [name: CuriousMessenger.PubSub, adapter: Phoenix.PubSub.PG2]}
      ]
    end

    First, let's add subscribing to the conversation's specific channel to conversation_live.ex in handle_params:

    def handle_params(%{"conversation_id" => conversation_id, "user_id" => user_id}, _uri, socket) do
      CuriousMessengerWeb.Endpoint.subscribe("conversation_#{conversation_id}")
      # ...
    end

    As you can see, the topic name is just a plain old string.

    Then, right after we add user information to new_message:

    CuriousMessengerWeb.Endpoint.broadcast_from!(self(), "conversation_#{conversation_id}", "new_message", new_message)

    The semantic of broadcast_from!/4 is that the message will be broadcast to all subscribed process except for self(), because the handle_event callback updates socket assigns with new messages anyway. Other processes will do it via a new handle_info callback:

    def handle_info(%{event: "new_message", payload: new_message}, socket) do
      updated_messages = socket.assigns[:messages] ++ [new_message]
    
      {:noreply, socket |> assign(:messages, updated_messages)}
    end

    This will work, but you can see we're duplicating the reassignment of newly updated messages to our socket.

    It is a common pattern in distributed programming to treat messages from self() just as if they were coming from anyone else. Let's replace the broadcast_from!/4 call with broadcast!/3:

    CuriousMessengerWeb.Endpoint.broadcast!("conversation_#{conversation_id}", "new_message", new_message)

    Remove the updated_messages construction and assignning it to the socket from handle_event, and just add {:noreply, socket} after the case statement - we won't do any socket assigns modification, this will all be handled by handle_info which receives the broadcast.

    Let's also require Logger at the top of the ConversationLive module and add the following clause inside the case statement so that we don't "swallow" any error that might happen:

    {:ok, new_message} ->
      # ...
    
    {:error, err} ->
      Logger.error(inspect(err))

    And that's it - when you repeat the test and run two browsers side-by-side with the same conversation, one view will react to the other sending messages.

    As an exercise, you can try using Phoenix PubSub to notify different users of the app about created conversations that involve them.

    LiveView template files

    For further convenience, and to better separate concerns, let's create a separate file that will contain the template rendered by our LiveView's render function.

    Put the contents of the ~L sigil into the lib/curious_messenger_web/templates/conversation/show.html.leex file. Create the very simple lib/curious_messenger_web/views/conversation_view.ex file to define a view (its render function will be automatically compiled from that .html.leex file we created):

    defmodule CuriousMessengerWeb.ConversationView do
      use CuriousMessengerWeb, :view
    end

    ...and refer to it in conversation_live.ex:

    alias CuriousMessengerWeb.ConversationView
    
    def render(assigns) do
      ConversationView.render("show.html", assigns)
    end

    We'll use a similar pattern in all of our subsequent LiveViews.

    Authenticate with Pow

    Note from 2024: Current versions of Phoenix and LiveView can generate registration and authentication code for you, also supporting LiveView. It is probably a better recommendation as of now - but you can still try it out the way we described it with Pow.

    Pow is a "robust, modular, and extendable authentication and user management solution for Phoenix and Plug-based apps", according to its docs.

    And that couldn't be more accurate. This is a field-tested library powering many production-grade apps, it has a modular design that allows you to easily enable or disable features such as password reset, email confirmations, persistent sessions or invitation-driven sign-up.

    Pow basic setup

    First, add the following to deps function in mix.exs and run mix deps.get:

    {:pow, "~> 1.0.21"}

    Now, what the docs recommend that we do is run mix pow.install to generate a user schema and a migration creating a DB table for users. We'll do this, but here's where we need to be a bit cautious, because this command is intended for brand-new apps, and we've already got an auth_users table and a CuriousMessenger.Auth.User schema.

    So after we've run the generator, we can see the following additions - starting from a generated CuriousMessenger.Users.User schema:

    use Pow.Ecto.Schema
    
    schema "users" do
      pow_user_fields()
      # ...
    end

    The pow_user_fields macro just adds email and password_hash fields to the schema. Let's move these new things over to our existing CuriousMessenger.Auth.User schema and ditch the new file whatsoever.

    Let's also add the following to the changeset definition for the User schema:

    def changeset(user, attrs) do
      user
      |> pow_changeset(attrs)
      |> # ...
    end

    pow_changeset is just a pipeline of functions ensuring that, given attrs contain an email and password, validates their presence and assigns an encrypted password_hash value that is then stored in the database.

    There is also a new migration file, priv/repo/migrations/2019..._create_users.exs, creating a users table with email and password_hash. Let's rename the module to AddPowFieldsToUsers and the file name's suffix accordingly. Instead of creating a new table, we'll alter the existing table:

    alter table(:auth_users) do
      add :email, :string, null: false
      add :password_hash, :string
    end
    
    create unique_index(:auth_users, [:email])

    Since we're adding a not-null column to a table that already has data, before running migrations we'll need to mix ecto.drop and mix ecto.create again so that we've got a clean database - of course it wouldn't be so easy if we had already deployed the app to production, but at this early stage it's OK to do this locally. Afterwards, just run mix run ecto.migrate to create the new fields.

    We'll need to notify Pow that we've changed its default setting, because out of the box it expects the user schema to be in Users.User, and we've got it in Auth.User - so put this in config/config.exs:

    config :curious_messenger, :pow,
      user: CuriousMessenger.Auth.User,
      repo: CuriousMessenger.Repo,
      web_module: CuriousMessengerWeb

    Sessions need to be stored in our app's session cookie, and, according to the docs,

    The user struct will be collected from a cache store through a GenServer using a unique token generated for the session.

    The cache store is set to use the in-memory Erlang Term Storage by default, which is about OK for development, but will fail you in production because it goes away when you restart the Erlang VM.

    We need a persistent storage solution then, and in this case we will demonstrate the usage of Mnesia cache module driven by Erlang's built-in Mnesia DBMS, though there are also other solutions, e.g. pow_postgres_store.

    Go to lib/curious_messenger_web/endpoint.ex and add this after the Plug.Session plug:

    plug Pow.Plug.Session,
      otp_app: :curious_messenger,
      cache_store_backend: Pow.Store.Backend.MnesiaCache

    Mnesia will run as an Erlang process along with your app's supervision tree, but we need to add it to lib/curious_messenger/application.ex's start/2 function for it to start:

    def start(_type, _args) do
      children = [
        CuriousMessenger.Repo,
        CuriousMessengerWeb.Endpoint,
        Pow.Store.Backend.MnesiaCache
      ]
    
      # ...
    end

    It will automatically create a Mnesia.nonode@nohost directory as its storage, so it's a good idea to add /Mnesia.* to your .gitignore file.

    Pow routing and pages

    We'll need a number of new pages to handle registration and sign-in, for which Pow has default controller and view code. We'll do the minimal amount of work needed to get our setup up and running, so we won't be customizing all of them for now. Let's start from the router - go to router.ex and add the following to add Pow routes and ensure that certain pages require authentication:

    defmodule CuriousMessengerWeb.Router do
      use CuriousMessengerWeb, :router
      use Pow.Phoenix.Router # Pow route macros
    
      # ...
    
      pipeline :protected do
        plug Pow.Plug.RequireAuthenticated,
          error_handler: Pow.Phoenix.PlugErrorHandler
      end
    
      # Pow browser routes - remember not to add them to the existing scope referencing the
      # CuriousMessengerWeb module, but add a new block instead like this:
      scope "/" do
        pipe_through :browser
    
        pow_routes()
      end
    
      scope "/", CuriousMessengerWeb do
        pipe_through :browser
    
        get "/", PageController, :index
      end
    
      # Make conversation routes protected by requiring authentication
      scope "/", CuriousMessengerWeb do
        pipe_through [:browser, :protected]
    
        resources "/conversations", ConversationController
    
        live "/conversations/:conversation_id/users/:user_id", ConversationLive, as: :conversation
      end
    
      # ...
    end

    To get an overview of what new routes have been generated by Pow, run mix phx.routes | grep Pow. It's going to look like this:

         pow_session_path  GET     /session/new        Pow.Phoenix.SessionController :new
         pow_session_path  POST    /session            Pow.Phoenix.SessionController :create
         pow_session_path  DELETE  /session            Pow.Phoenix.SessionController :delete
    pow_registration_path  GET     /registration/edit  Pow.Phoenix.RegistrationController :edit
    pow_registration_path  GET     /registration/new   Pow.Phoenix.RegistrationController :new
    pow_registration_path  POST    /registration       Pow.Phoenix.RegistrationController :create
    pow_registration_path  PATCH   /registration       Pow.Phoenix.RegistrationController :update
                           PUT     /registration       Pow.Phoenix.RegistrationController :update
    pow_registration_path  DELETE  /registration       Pow.Phoenix.RegistrationController :delete

    You can now replace the Get Started link in app.html.eex template with actual links to registration, sign-in, profile edit and logout:

    <%= if Pow.Plug.current_user(@conn) do %>
      <li><%= link "Profile", to: Routes.pow_registration_path(@conn, :edit) %></li>
      <li><%= link "Sign out", to: Routes.pow_session_path(@conn, :delete), method: :delete %></li>
    <% else %>
      <li><%= link "Register", to: Routes.pow_registration_path(@conn, :new) %></li>
      <li><%= link "Sign in", to: Routes.pow_session_path(@conn, :new) %></li>
    <% end %>

    Pow's default template doesn't contain the nickname field for users, which is required in our setup - so it won't work. We'll need to customize the form - we've already set web_module: CuriousMessengerWeb in the config, so let's run mix pow.phoenix.gen.templates to generate view and template files.

    We'll leave most of the files untouched (but not delete them), and focus mostly on lib/curious_messenger_web/templates/pow/registration/new.html.eex and lib/curious_messenger_web/templates/pow/registration/edit.html.eex to modify templates for registration and profile edit. Let's just add an additional nickname field to both:

    <%= label f, :nickname %>
    <%= text_input f, :nickname %>
    <%= error_tag f, :nickname %>

    We're good to go, and the registration functionality should now be working - here's the GitHub revision at the current stage, if you'd like to start from here.

    Group conversations

    Our Messenger app is useless when users can't create conversations on their own, and we'll now deal with this.

    Here's what it's going to look like - just for a start, we'd like to be able to display a list of ongoing conversations, and of course also initiate them: Phoenix LiveView Messenger App - Conversations and Contacts

    Schema modifications

    Since we'd also like to exercise using Phoenix's form helpers with changesets and nesting association data, we're going to fix up a few of our schema definitions.

    Let's add the following to user.ex:

    alias CuriousMessenger.Chat.ConversationMember
    
    schema "auth_users" do
      # ...
    
      has_many :conversation_members, ConversationMember
      has_many :conversations, through: [:conversation_members, :conversation]
    end

    This will ensure that conversation members and conversations can be understood by Ecto as associations, e.g. for the purpose of preloading.

    Conversely, in conversation.ex we need to add the following:

    schema "chat_conversations" do
      has_many :conversation_members, ConversationMember
      has_many :conversations, through: [:conversation_members, :conversation]
    end
    
    def changeset(conversation, attrs) do
      |> cast(attrs, [:title])
      |> cast_assoc(:conversation_members)
      |> validate_required([:title])
    end

    This is just the other side of the ConversationMember many-to-many relationship (note, though, we don't use Ecto's many_to_many declaration, because we have an additional owner column in the intermediate table). The cast_assoc thing is needed for us to make us able to use Chat.create_conversation/1 with the following argument:

    %{
      "conversation_members" => %{
        "0" => %{"user_id" => "3"},
        "1" => %{"user_id" => "2"},
        "2" => %{"user_id" => "1"},
        "3" => %{"user_id" => "4"}
      },
      "title" => "Curious Conversation"
    }

    Lastly, let's make the ConversationMember's changeset look like the following:

    def changeset(conversation_member, attrs) do
      conversation_member
      |> cast(attrs, [:owner, :user_id])
      |> validate_required([:owner, :user_id])
      |> unique_constraint(:user, name: :chat_conversation_members_conversation_id_user_id_index)
      |> unique_constraint(:conversation_id, name: :chat_conversation_members_owner)
    end

    Notice how we don't validate the requirement for conversation_id to be present: we can't do this because we want these records to be created along with a conversation.

    Designing the messenger dashboard LiveView

    The dashboard will be driven by a LiveView module that will manage the list of currently available conversations and adding a new conversation.

    At all times, it will need to know about the following - which will constitute its state:

    • who the current_user is (and what are it's available associated conversations),
    • what are the contacts (let's assume that, for simplicity, it's just all of the app's registered users),
    • for the purpose of creating a new conversation - the users that the current user currently wants to add to a new conversation.

    Create the lib/curious_messenger_web/live/dashboard_live.ex file. Let's start with declaring a very basic setup. In this example, we'll want to define the container element, in which the LiveView will be rendered (the purpose of which will get clear when we get to the template code). We'll also use Phoenix's HTML helpers, and alias a few of the modules we're going to use. Last but not least, we'll mount the component, having ensured that current_user is provided.

    defmodule CuriousMessengerWeb.DashboardLive do
      require Logger
    
      use Phoenix.LiveView, container: {:div, [class: "row"]}
      use Phoenix.HTML
    
      alias CuriousMessenger.{Auth, Chat}
      alias CuriousMessenger.Chat.Conversation
      alias CuriousMessengerWeb.DashboardView
      alias CuriousMessenger.Repo
      alias Ecto.Changeset
    
      def render(assigns) do
        DashboardView.render("show.html", assigns)
      end
    
      def mount(_params, %{"current_user" => current_user}, socket) do
        {:ok,
         socket
         |> assign(current_user: current_user)
         |> assign_new_conversation_changeset()
         |> assign_contacts(current_user)}
      end
    
      # Build a changeset for the newly created conversation, initially nesting a single conversation
      # member record - the current user - as the conversation's owner.
      #
      # We'll use the changeset to drive a form to be displayed in the rendered template.
      defp assign_new_conversation_changeset(socket) do
        changeset =
          %Conversation{}
          |> Conversation.changeset(%{
            "conversation_members" => [%{owner: true, user_id: socket.assigns[:current_user].id}]
          })
    
        assign(socket, :conversation_changeset, changeset)
      end
    
      # Assign all users as the contact list.
      defp assign_contacts(socket, current_user) do
        users = Auth.list_auth_users()
    
        assign(socket, :contacts, users)
      end
    end

    Notice that we don't implement handle_params - we're going to exercise including this LiveView inside a different template, as opposed to what we did with conversations (a standalone route).

    Create the DashboardView module in lib/curious_messenger_web/views/dashboard_view.ex. Let's define a number of decorator functions that we'll use in the corresponding template for clarity and readability. Notice how we use phx_click to make links emit the LiveView add_member and remove_member events, and phx_value_user_id to make these events carry additional values retrievable under the user-id key.

    defmodule CuriousMessengerWeb.DashboardView do
      use CuriousMessengerWeb, :view
    
      def remove_member_link(contacts, user_id, current_user_id) do
        nickname = contacts |> Enum.find(&(&1.id == user_id)) |> Map.get(:nickname)
    
        link("#{nickname} #{if user_id == current_user_id, do: "(me)", else: "✖"} ",
          to: "#!",
          phx_click: unless(user_id == current_user_id, do: "remove_member"),
          phx_value_user_id: user_id
        )
      end
    
      def add_member_link(user) do
        link(user.nickname,
          to: "#!",
          phx_click: "add_member",
          phx_value_user_id: user.id
        )
      end
    
      def contacts_except(contacts, current_user) do
        Enum.reject(contacts, &(&1.id == current_user.id))
      end
    
      def disable_create_button?(assigns) do
        Enum.count(assigns[:conversation_changeset].changes[:conversation_members]) < 2
      end
    end

    We'll need a lib/curious_messenger_web/templates/dashboard/show.html.leex file as the template for our dashboard. Thanks to the functions defined above, it's going to look simple and clear.

    It's got two class="column" elements (which is why we needed the LiveView container to have the row class). In the first column, we display all the ongoing conversations, with links using the simple route we previously created.

    The second column contains a form driven by the LiveView's assigned @conversation_changeset. It repeats the changeset structure using form_for @conversation_changeset, ... and its nested inputs_for f, :conversation_members declaration, inside which there are removal links for each added member and hidden inputs that ensure the form's encoded data will match the structure that we expect to be given to changesets. There are also member add links for each user, and a field to define the conversation's title.

    On form submit, the create_conversation event is being emitted, and the encoded form data is passed in the event's payload, allowing us to use it in a Chat.create_conversation/1 call.

    <article class="column">
      <h2>Ongoing Conversations</h2>
      <%= for conversation <- @current_user.conversations do %>
        <div>
          <%= link conversation.title,
                   to: Routes.conversation_path(@socket,
                                                CuriousMessengerWeb.ConversationLive,
                                                conversation.id,
                                                @current_user.id) %>
        </div>
      <% end %>
    </article>
    
    <article class="column">
      <h2>Create Conversation</h2>
    
      <%= form_for @conversation_changeset, "", [phx_submit: :create_conversation], fn f -> %>
        <p>
          <%= inputs_for f, :conversation_members, fn cmf -> %>
            <%= remove_member_link(@contacts, cmf.source.changes[:user_id], @current_user.id) %>
    
            <%= hidden_input cmf, :user_id, value: cmf.source.changes[:user_id] %>
          <% end %>
        </p>
    
        <p>
          <%= text_input f, :title, placeholder: "Title (optional)" %>
          <%= submit "Create", disabled: disable_create_button?(assigns) %>
        </p>
    
        <ul>
          <%= for user <- contacts_except(@contacts, @current_user) do %>
            <li>
              <%= add_member_link(user) %>
            </li>
          <% end %>
        </ul>
      <% end %>
    </article>

    We have three events to handle: create_conversation, add_member and remove_member. Define the following clauses for handle_event/3 in dashboard_live.ex along with a helper function:

    # Create a conversation based on the payload that comes from the form (matched as `conversation_form`).
    # If its title is blank, build a title based on the nicknames of conversation members.
    # Finally, reload the current user's `conversations` association, and re-assign it to the socket,
    # so the template will be re-rendered.
    def handle_event(
          "create_conversation",
          %{"conversation" => conversation_form},
          %{
            assigns: %{
              conversation_changeset: changeset,
              current_user: current_user,
              contacts: contacts
            }
          } = socket
        ) do
      conversation_form =
        Map.put(
          conversation_form,
          "title",
          if(conversation_form["title"] == "",
            do: build_title(changeset, contacts),
            else: conversation_form["title"]
          )
        )
    
      case Chat.create_conversation(conversation_form) do
        {:ok, _} ->
          {:noreply,
            assign(
              socket,
              :current_user,
              Repo.preload(current_user, :conversations, force: true)
            )}
    
        {:error, err} ->
          Logger.error(inspect(err))
      end
    end
    
    # Add a new member to the newly created conversation.
    # "user-id" is passed from the link's "phx_value_user_id" attribute.
    # Finally, assign the changeset containing the new member's definition to the socket,
    # so the template can be re-rendered.
    def handle_event(
          "add_member",
          %{"user-id" => new_member_id},
          %{assigns: %{conversation_changeset: changeset}} = socket
        ) do
      {:ok, new_member_id} = Ecto.Type.cast(:integer, new_member_id)
    
      old_members = socket.assigns[:conversation_changeset].changes.conversation_members
      existing_ids = old_members |> Enum.map(&(&1.changes.user_id))
    
      cond do
        new_member_id not in existing_ids ->
          new_members = [%{user_id: new_member_id} | old_members]
    
          new_changeset = Changeset.put_change(changeset, :conversation_members, new_members)
    
          {:noreply, assign(socket, :conversation_changeset, new_changeset)}
    
        true ->
          {:noreply, socket}
      end
    end
    
    # Remove a member from the newly create conversation and handle it similarly to
    # when a member is added.
    def handle_event(
          "remove_member",
          %{"user-id" => removed_member_id},
          %{assigns: %{conversation_changeset: changeset}} = socket
        ) do
      {:ok, removed_member_id} = Ecto.Type.cast(:integer, removed_member_id)
    
      old_members = socket.assigns[:conversation_changeset].changes.conversation_members
      new_members = old_members |> Enum.reject(&(&1.changes[:user_id] == removed_member_id))
    
      new_changeset = Changeset.put_change(changeset, :conversation_members, new_members)
    
      {:noreply, assign(socket, :conversation_changeset, new_changeset)}
    end
    
    defp build_title(changeset, contacts) do
      user_ids = Enum.map(changeset.changes.conversation_members, &(&1.changes.user_id))
    
      contacts
      |> Enum.filter(&(&1.id in user_ids))
      |> Enum.map(&(&1.nickname))
      |> Enum.join(", ")
    end

    To render the DashboardLive component on the landing page, go to index.html.eex and insert right after the phx-hero section:

    <%= if @current_user do %>
      <%= live_render(@conn,
                      CuriousMessengerWeb.DashboardLive,
                      session: %{"current_user" => @current_user}) %>
    <% end %>

    That's it! You're now able to sign up, log in and manage your conversations with different users of the app. Here's the current revision at GitHub.

    Phoenix LiveView vs HTTP and offline work

    We were keen to point out that Phoenix LiveView can be treated as an alternative to reactive UI frameworks such as React or Vue.

    While this is true, let's think about what can go wrong with full reliance on Phoenix LiveView's proposed approach.

    Server-side statefulness, which is Phoenix LiveView's major selling point, can also be seen as its major weakness, creating several challenges to resolve.

    HTTP is stateless by nature. You pass and mutate cookies back and forth between the browser and the server to create an illusion of state, storing a set of data - a session.

    What happens in HTTP when the server's down? HTTP is stateless, so there's not much to worry about. You have your cookies stored in the browser.

    What happens in HTTP when the connection's down? Similarly, once a page is loaded and cookies stored, connection does not matter. Well, technically there is not even such a thing as a connection in HTTP. It's a term related to the TCP protocol, which is HTTP's transport layer.

    What happens in HTTP when an unhandled server exception occurs in data processing? HTTP is stateless, so there's not much to worry about. The server will bail out by throwing a 500, but remains stable and responds to requests, and you still have your state in the cookie.

    With LiveView, forget what you're used to in HTTP.

    Each instance of a user seeing a LiveView is backed by a stateful Elixir process supervised inside an Erlang VM instance. So whatever you'd like to call a session is part of the process's internal state.

    What happens when the server is down? Suppose that the whole Erlang VM has crashed. By default, any state that may have been present is obviously lost.

    What happens when the connection is down? Well, Phoenix Channels (backing all LiveViews) are monitored, and when a channel is down, the LiveView process is terminated. Therefore, state is lost.

    What happens when an unhandled LiveView process exception occurs? This means the function that processes a state transition has no way to transform the process to a next state, so the process crashes. State is lost.

    Let's see how we can overcome some of the challenges we've identified.

    Reconnecting to the server

    As we've noted, regardless of whether it's caused by the server going entirely down, or by losing connection between the browser at the server, it causes the LiveView process to lose its current state.

    We'll do a quick test to learn at how Phoenix LiveView marks "dead" LiveViews in a page.

    Phoenix LiveView uses .phx-disconnected and .phx-error selectors to visually mark tags that contain malfunctioning LiveView components.

    This can be important, because it allows the developer to identify these DOM elements as belonging to a failing LiveView, which is important from a UX standpoint - it needs to be clear that e.g. a form inside such a failing view cannot be interacted with.

    To test it, let's add the following selector in assets/css/app.css:

    .phx-error {
      opacity: .25;
      cursor: not-allowed;
    }
    
    .phx-error * {
      pointer-events: none;
    }

    Before we proceed and start the server to test it out, let's temporarily disable watching for file changes and live-reloading the app's pages, because this would interfere with what we want to test here. To do this, jump into /lib/curious_messenger_web/endpoint.ex and find and comment out the line that declares plug Phoenix.LiveReloader. Its surroundings should look like this:

    if code_reloading? do
      socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
      # plug Phoenix.LiveReloader
      plug Phoenix.CodeReloader
    end

    Let's now open up the server by executing mix.phx.server, navigate to http://localhost:4000 and sign into a registered user.

    If you dig into the DOM inspector at this point, you'll notice that there is a <div> element that has the .phx-connected class - this is the LiveView's main container.

    Since our LiveView's internal state is represented by a changeset corresponding to our "Create Conversation" form, storing IDs of users to be added, let's add them to the to-be-created conversation, but instead of submitting the creation form, terminate the Phoenix server. Here is what you should see:

    Phoenix LiveView Messenger App - LiveView Disconnected From Server

    Note that, as we said, the element now becomes annotated with .phx-disconnected and .phx-error, and the CSS we wrote provides a basic way to distinguish a failed component from the rest of the page.

    Now let's start the Phoenix server again. The browser will reconnect to the server promptly, but clearly the form will now be in its initial state, with nobody but the current user added to the conversation.

    To test the scenario of a user's connection going down while the server is still up, connect to the app using your local IP address (as opposed to localhost which uses a loopback interface) and disconnect from your network - and then reconnect - you'll again notice that the form is now back in the initial state, which means that the process that held the pre-disconnect state has been terminated.

    This is actually good, because having to deal with dangling processes of lost LiveView connections would be disastrous. But it obviously also means that we need to make some conscious decisions about how we deal with reconnecting, if the LiveView's state contains some non-persistent data.

    We'll look into how to tackle this in a moment. Now let's create a conversation and get into its view, and we'll see if we need to do anything with ConversationLive with regard to behavior on reconnecting.

    One could imagine that the most annoying thing that could happen is to lose a message you've been typing when connection issues occur. Will it happen? Let's find out: type in a message and close the server instead of submitting.

    Phoenix LiveView Messenger App - Conversation Disconnected From Server

    Reopen the server. You surely have noticed that the message is still there. Isn't that inconsistent with what we've seen with the behaviour of DashboardLive? Let's do one more experiment to find out what's going on.

    Go into /lib/curious_messenger_web/templates/conversation/show.html.leex and find the line that declares the message content field:

    <%= text_input f, :content %>

    ...and modify it to have a programmatically assigned value - it needn't be anything meaningful, let's just use a function from Erlang's :rand module:

    <%= text_input f, :content, value: :rand.uniform() %>

    Refresh the page. You'll see a number entered into the "Content" field. Now enter whatever text you want in that field, close the server and reopen it. You'll notice that the text you had entered is lost, and there is a different random number entered in the field now.

    Long story short, Phoenix LiveView is very good at splitting templates into static and dynamic parts, as well as identifying those elements in the DOM that needn't be replaced. While the <%= text_input f, :content %> line is dynamic by nature regardless of whether there's a random number used there, it is rendered identically every single time - so it doesn't need to be replaced, because changing the field's value doesn't create any DOM change.

    Controlling reconnection, restoring state

    Coming back to the DashboardLive module - we elected to store the form's state as an Ecto.Changeset because it's pretty idiomatic to anyone familiar with Ecto and very easy to persist once we're done with selecting contacts to chat with. It's easy to reason about changesets when there are forms that correspond to a specific Ecto schema defining a changeset - in this case, we've got a Conversation that accepts nested attributes for associated ConversationMember records.

    The changeset is a part of the component's state which can't be easily retrieved from elsewhere, so let's think of how we can make it at least a bit fail-safe.

    If we wanted to make the form state remain intact between page visits, we could think of server-side solutions such as continously storing data related to the current user's form state in the relational database (PostgreSQL), Mnesia or the Erlang Term Storage (the latter being non-persistent, though). Stored data would then be retrieved at the moment the LiveView is mounted.

    One could be tempted to think about cookies. But we don't want to use classic HTTP, and Phoenix's secure session cookie cannot be manipulated without getting into the HTTP request-response cycle - which we don't want to do.

    With client-side (or mixed) approaches, it gets a bit more complicated. Depending on the nature of processed data, it might be acceptable to use browser's mechanisms such as localStorage to store the form's state. And, finally, there is the need to actually write some JS code to feed the LiveView with an initial, post-reconnect state.

    Since in the case of the "Create Conversation" form we don't necessarily need it to be persistent between page reloads, we'll only make it fail-safe in reconnection scenarios described earlier.

    JavaScript Hooks

    Phoenix LiveView allows us to write JS functions reacting to a LiveView instance's lifecycle events. According to the documentation, we can react to the following events:

    • mounted - the element has been added to the DOM and its server LiveView has finished mounting
    • updated - the element has been updated in the DOM by the server
    • destroyed - the element has been removed from the page, either by a parent update, or the parent being removed entirely
    • disconnected - the element's parent LiveView has disconnected from the server
    • reconnected - the element's parent LiveView has reconnected to the server

    Create a /assets/js/create_conversation_form_hooks.js file:

    const CreateConversationFormHooks = {
      disconnected() {
        console.log("Disconnected", this)
      },
    
      reconnected() {
        console.log("Reconnected", this)
      },
    
      mounted() {
        console.log("Mounted", this)
      },
    
      destroyed() {
        console.log("Destroyed", this)
      },
    
      disconnected() {
        console.log("Disconnected", this)
      },
    
      updated() {
        console.log("Updated", this)
      }
    }
    
    export default CreateConversationFormHooks

    In /assets/js/app.js, ensure that the hooks are loaded and passed to the constructor of LiveSocket. Let's have the file contain exactly the following code (excluding comments):

    import css from "../css/app.css"
    import "phoenix_html"
    
    import { Socket } from "phoenix"
    import LiveSocket from "phoenix_live_view"
    import CreateConversationFormHooks from "./create_conversation_form_hooks";
    
    let Hooks = { CreateConversationFormHooks };
    
    let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks })
    liveSocket.connect()

    Fine, but how does LiveView know what view instance to react on? For this purpose, we annotate an element within a template with the phx-hook attribute. In this case, we'll do this with the form we use for creating a conversation.

    In /lib/curious_messenger_web/templates/dashboard/show.html.leex, let's have the form declared like this:

    <%= form_for @conversation_changeset, "", [id: "create-conversation-form", phx_submit: :create_conversation, phx_hook: "CreateConversationFormHooks"], fn f -> %>
    ...
    <% end %>

    If you open up your browser's developer tools and refresh the page at this point, you'll be able to observe what events are initially triggered - clearly these are mounted and updated. When you do anything that makes the LiveView update its state (e.g. add a conversation member), the updated event is fired again. The destroyed event would be triggered when e.g. a LiveView (possibly nested) completely disappears from the page - experience tells that it is not a reliable event on e.g. a page exit.

    Now, try closing and restarting the Phoenix server, and here's what you'll probably end up with:

    Phoenix LiveView Messenger App - Lifecycle Events Logged With JS Hooks

    Most importantly, disconnected and reconnected happened. Conceptually, what we'd like to do is serialize the form to a changeset-friendly representation (e.g. URL-encoded form data) and send it over the LiveView's WebSocket connection when the server is up again (reconnected).

    Inside these hook functions, this is a special object encapsulating the client-side LiveView representation and allowing us to communicate with the server, most importantly to send events that we'll handle in Elixir using handle_event callbacks.

    Let's now change the reconnected function definition to the following:

      reconnected() {
        console.log("Reconnected", this)
        let formData = new FormData(this.el)
        let queryString = new URLSearchParams(formData)
        this.pushEvent("restore_state", { form_data: queryString.toString() })
      }

    This will serialize the form into a form that we'll then convert into a changeset at the Elixir side. Recall that, at this point, the LiveView has been mounted and its process is up and running, so let's make it respond to the restore_state event we send from JS code. In /lib/curious_messenger_web/live/dashboard_live.ex, add a new clause for handle_event/3:

      def handle_event("restore_state", %{"form_data" => form_data}, socket) do
        # Decode form data sent from the pre-disconnect form
        decoded_form_data = Plug.Conn.Query.decode(form_data)
    
        # Since the new LiveView has already run the mount function, we have the changeset assigned
        %{assigns: %{conversation_changeset: changeset}} = socket
    
        # Now apply decoded form data to that changeset
        restored_changeset =
          changeset
          |> Conversation.changeset(decoded_form_data["conversation"])
    
        # Reassign the changeset, which will then trigger a re-render
        {:noreply, assign(socket, :conversation_changeset, restored_changeset)}
      end

    Psst! If you follow this pattern in multiple LiveViews, do yourself a favor and extract this into a reusable module.

    Now, when you try disconnecting and reconnecting again, your "Create Conversation" form will be restored to the pre-disconnect state.

    Reacting to new conversations

    As an exercise, we suggested that you can (and should) use Phoenix PubSub to notify a user's DashboardLive view when they've got a new conversation they participate in.

    Again, to recap, it's just about making conversation creation broadcast a notification to all interested members - let's go to /lib/curious_messenger/chat.ex and rework the create_conversation function:

      def create_conversation(attrs \\ %{}) do
        result =
          %Conversation{}
          |> Conversation.changeset(attrs)
          |> Repo.insert()
    
        case result do
          {:ok, conversation} ->
            conversation.conversation_members
            |> Enum.each(
              &CuriousMessengerWeb.Endpoint.broadcast!(
                "user_conversations_#{&1.user_id}",
                "new_conversation",
                conversation
              )
            )
    
            result
    
          _ ->
            result
        end
      end

    This needs to be reacted to in DashboardLive, so we need to make changes in /lib/curious_messenger_web/live/dashboard_live.ex.

    In the mount/2 callback, insert the following code at the beginning:

      def mount(%{current_user: current_user}, socket) do
        CuriousMessengerWeb.Endpoint.subscribe("user_conversations_#{current_user.id}")
    
        # prior implementation
      end

    Since the information about a new conversation being created can now come from elsewhere as well as from self, we'll now handle this information in handle_info instead of handle_event - the latter's clause for create_conversation can now be simplified:

      def handle_event("create_conversation", %{"conversation" => conversation_form},
                       %{assigns: %{conversation_changeset: changeset, contacts: contacts}} = socket) do
        title = if conversation_form["title"] == "" do
                  build_title(changeset, contacts)
                else
                  conversation_form["title"]
                end
    
        conversation_form = Map.put(conversation_form, "title", title)
    
        case Chat.create_conversation(conversation_form) do
          {:ok, _} ->
            {:noreply, socket}
    
          {:error, err} ->
            Logger.error(inspect(err))
            {:noreply, socket}
        end
      end

    ...and a new handle_info clause for new_conversation can be added:

      def handle_info(%{event: "new_conversation", payload: new_conversation}, socket) do
        user = socket.assigns[:current_user]
        user = %{user | conversations: user.conversations ++ [new_conversation]}
    
        {:noreply, assign(socket, :current_user, user)}
      end

    This way, users creating conversations for each other will get live updates of their conversation lists.

    Intercepting events and creating notifications

    Phoenix LiveView itself does not expose an API to push messages from the server to the browser; everything that's pushed in this direction is DOM diffs.

    You could think of leveraging Phoenix Channels, also built on top of Phoenix PubSub, to handle this kind of notifications; or, to keep your technological stack thin, you can just rely on JS updated hooks interpreting data present in the new DOM.

    Let's enhance the newly added handle_info clause and piggyback a notify flag on the newly created record. Remember how we once explained that a struct is just a map?

      def handle_info(%{event: "new_conversation", payload: new_conversation}, socket) do
        user = socket.assigns[:current_user]
        annotated_conversation = new_conversation |> Map.put(:notify, true)
        user = %{user | conversations: (user.conversations |> Enum.map(&(Map.delete(&1, :notify)))) ++ [annotated_conversation]}
    
        {:noreply, assign(socket, :current_user, user)}
      end

    Consume this in the /lib/curious_messenger_web/templates/dashboard/show.html.leex view the following way - plugging in the new ConversationListHooks that we'll create in a moment into the conversation list.

    <article id="conversation-list" class="column" phx-hook="ConversationListHooks">
      <h2>Ongoing Conversations</h2>
      <%= for conversation <- @current_user.conversations do %>
        <div>
          <%= link conversation.title,
              to: Routes.conversation_path(@socket, CuriousMessengerWeb.ConversationLive, conversation.id, @current_user.id),
              data: if Map.get(conversation, :notify), do: [notify: true], else: [] %>
        </div>
      <% end %>
    </article>

    This way, only one conversation link will be annotated with the data-notify attribute that we'll then look for in JS code so that we can notify the user about it.

    Add the following import to /assets/js/app.js and modify the Hooks declaration. Also ask the user for permission to display notifications:

    import ConversationListHooks from "./conversation_list_hooks";
    let Hooks = { CreateConversationFormHooks, ConversationListHooks };
    
    Notification.requestPermission() // returns a Promise that we're not much interested in for now

    ...and create a new file in /assets/js/conversation_list_hooks.js. Notice that, in this case, this.el is the element to which the hook was attached in the HTML template:

    const ConversationListHooks = {
      updated() {
        let newConversationLink = this.el.querySelector('[data-notify]')
        if (!newConversationLink) return
    
        let notification = new Notification(newConversationLink.innerText)
        notification.onclick = () => window.open(newConversationLink.href)
      }
    }
    
    export default ConversationListHooks

    This way, everyone involved with the conversation will now receive a push notification when it is created.

    It is slightly imperfect, because the notification will also be displayed to the user who created the conversation. Nonetheless, it is a good starting point to expand the push functionality to messages as well.

    Phoenix LiveView Messenger App - JavaScript Push Notifications On Conversation Creation

    Notifying user from the browser

    Let's get to messages now. Add an import and change the hooks declaration again in /assets/js/app.js:

    import ConversationHooks from "./conversation_hooks"
    
    let Hooks = { CreateConversationFormHooks, ConversationListHooks, ConversationHooks }

    Create a new file in /assets/js/conversation_hooks.js. We'll rely on incoming messages - that we want to have notifications on - to have a data-incoming attribute.

    const ConversationHooks = {
      updated() {
        if (!this.notifiedMessages)
          this.notifiedMessages = []
    
        this.el.querySelectorAll('[data-incoming]').forEach(element => {
          if (!this.notifiedMessages.includes(element)) {
            this.notifiedMessages.push(element)
            let notification = new Notification(element.innerText)
            notification.onclick = () => window.focus()
          }
        })
      }
    }
    
    export default ConversationHooks

    Go to /lib/curious_messenger_web/templates/conversation/show.html.leex. Find the for loop that renders message markup and replace it like this, so that if a message is annotated with an additional :incoming key, the div tag should be displayed with an additional data attribute:

        <div id="conversation" phx-hook="ConversationHooks">
          <%= for message <- @messages do %>
            <%= if Map.get(message, :incoming) do %>
              <div data-incoming="true">
            <% else %>
              <div>
            <% end %>
              <b><%= message.user.nickname %></b>: <%= message.content %>
            </div>
          <% end %>
        </div>

    Then, go to /lib/curious_messenger_web/live/conversation_live.ex and modify the definition for handle_info dealing with new messages:

      def handle_info(%{event: "new_message", payload: new_message}, socket) do
        annotated_message =
          if new_message.user.id != socket.assigns[:user].id do
            new_message |> Map.put(:incoming, true)
          else
            new_message
          end
    
        updated_messages = socket.assigns[:messages] ++ [annotated_message]
    
        {:noreply, socket |> assign(:messages, updated_messages)}
      end

    This time, the message's notification will not appear for the user that sent it.

    You can now test this out - notifications will appear in your conversations! One caveat is that you'll only see them while the conversation tab is running. **If you'd like to use notifications that don't require the tab to be open, read up on the Push API and Service Workers - and, at the Elixir side of things, get familiar with the web_push_encryption library.

    What else can go wrong with Phoenix LiveView?

    It has to be kept in mind that, in LiveView, with the whole logic behind events and UI updates being on the server side of things.

    Server-side event handling makes LiveView far from ideal for aplications that require offline access**.

    If an application is intended for offline use, it might be a better idea to go for solutions that don't use LiveView for driving the logic behind form controls, and perhaps restrict it to displaying some live-updated data - which, with Phoenix PubSub available, is still very convenient.

    You could go more ambitious and use the disconnected hook to create a plug custom logic into your form when it has lost connection. If you've got an UI whose concept is simple and linear - for instance, let's say you've got a button which you count clicks on - you could try to trace any activity that occurs in the relevant controls (or a form) during the time the connection is down - and push it as events to the server using pushEvent when the connection is back. It's not perfect, though, because the UI will still not appear to be reactive during the connection outage.

    If your application requires full UI reactivity in offline mode, look for other solutions than Phoenix LiveView for the most critical components.

    Here's the repository of our app at this point.

    Wrapping up

    We've managed to explain a few of the issues you can run into in a production LiveView app, and resolve a few of them - learning how to use JS hooks to react to Phoenix LiveView lifecycle events.

    The application can now be used to chat between signed-in users and notify them via system-native push notifications when a new message is received.

    Feature-wise, some ideas for the future include the ability to react to messages with emoji as well as sharing external contents and dynamic building of previews. In terms of technical changes, we could introduce Phoenix Components and LiveComponents to the codebase - which is part of what we would like to update in the Phoenix LiveView tutorial when there is a chance to get at this.

    FAQ

    What is Phoenix LiveView and how does it work?

    Phoenix LiveView is an Elixir behavior that enables the creation of interactive and real-time web applications without extensive JavaScript. It works by maintaining UI state on the server and using WebSockets for real-time client-server communication.

    What are the basic components of a Phoenix LiveView?

    The basic components include the render/1 function for HTML representation and the mount/2 function for initializing state. Event handling is managed through handle_event/3 and handle_info/2 callbacks.

    How can Phoenix LiveView be used in web development?

    Phoenix LiveView is used for building real-time features such as live chat applications. It allows for server-side state management and client updates without needing additional frontend scripts.

    What are the initial setup steps for a Phoenix LiveView project?

    The setup involves installing Elixir, Phoenix, and NodeJS, creating a Phoenix project, configuring the database, and adding Phoenix LiveView as a dependency.

    How does database configuration work in Phoenix LiveView projects?

    Database configuration involves setting up dev.exs, test.exs, and prod.exs files for different environments and ensuring security through secret files for development and test configurations.

    What are the steps for installing Phoenix LiveView in a project?

    Installing Phoenix LiveView includes adding it to mix.exs, configuring a signing salt, updating router and endpoint files for WebSocket support, and ensuring the client-side JavaScript is set up for LiveView.

    How does Phoenix LiveView handle client-server communication?

    Phoenix LiveView uses WebSockets for bi-directional communication, allowing real-time updates between the server and the client without full page reloads.

    What are the key elements of building a real-time messaging application with Phoenix LiveView?

    Key elements include creating Ecto schemas for users, conversations, and messages, implementing LiveView for interactive UI components, and handling events for real-time updates.

    Michał Buszkiewicz, Elixir Developer
    Michał Buszkiewicz Curiosum Founder & CTO

    Read more
    on #curiosum blog