Phoenix LiveView Tutorial: Bootstrap Your Messenger App

Phoenix LiveView Tutorial: Bootstrap Your Messenger App

Initial steps: install Phoenix, 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.

Needless to say, Elixir and Erlang need to be installed, as well as NodeJS - we recommend the asdf-vm extensible version manager as a tool that can manage all of them.

Now create Phoenix's basic project structure using the installed script:

mix phx.new curious_messenger
cd curious_messenger

You can then git init . if you'd like to track changes with Git, which is always highly recommended.

Database configuration

We won't stray from Phoenix's default setting and we're going to use PostgreSQL as our database for storing all of the app's data, including users, conversations, messages, and all metadata - have a look at PostgreSQL Wiki for guides on installing it.

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. It is a good practice to keep separate dev.secret.exs and test.secret.exs files just as it's done by default with prod.secret.exs, and ignore them in source control; sample files can be provided for easier bootstrapping.

So, let's create dev.secret.exs and test.secret.exs files in the same folder, with database config copied from the original ones - obviously, if you're using different usernames, or would like to change other options, just do so:

# 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

Put import_config "dev.secret.exs" and import_config "test.secret.exs" at the end of dev.exs and test.exs, respectively.

Then, add /config/dev.secret.exs and /config/test.secret.exs to your .gitignore file. 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.

This is a good moment to make an initial commit, by the way, if you'd like to.

Now, let Ecto, which is Phoenix's default data access library, create the database in your local DB server for you:

mix ecto.create

Analyze requirements, define contexts and Ecto schemas

Before we proceed to add LiveView to our project, let's design the data model driving the app's intended business logic.

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.

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.

We would also like to have a Slack-like emoji reaction system, in which any conversation member can react to a message with one or more defined emojis, all of which have a name and a Unicode representation.

Phoenix Contexts

Phoenix promotes the concept of contexts to organize your business logic code and encapsulate the data access layer. According to Phoenix docs: >The context is an Elixir module that serves as an API boundary for the given resource. A context often holds many related resources.

The good thing about this approach is that we'll have our context modules talk to the Ecto schema modules, 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 in later episodes 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.
    • Emoji, having a string key and a unicode representation, defining an emoji that can be used as a reaction.
    • MessageReaction, belonging to a message, user and emoji, with one user being able to react to a message with many emojis, but only once for each emoji.

The Chat context could be split into smaller parts, but let's keep it simple for now and leave it as it is.

Phoenix has a very handy phx.gen.context generator to automatically generate Ecto schemas, database migrations and CRUD functions for each schema, so we'll now use it to conveniently create those.

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 Emoji chat_emojis \
  key:string \
  unicode:string

mix phx.gen.context Chat MessageReaction chat_message_reactions \
  message_id:references:chat_messages \
  user_id:references:auth_users \
  emoji_id:references:chat_emojis

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 and emoji reactions:

alias CuriousMessenger.Auth.User
alias CuriousMessenger.Chat.{Conversation, SeenMessage, MessageReaction}

schema "chat_messages" do
  field :content, :string

  belongs_to :conversation, Conversation
  belongs_to :user, User

  has_many :seen_messages, SeenMessage
  has_many :message_reactions, MessageReaction

  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

Emoji needs to have not-null constraints in the migration:

add :key, :string, null: false
add :unicode, :string, null: false

As well as a requirement validation for those in the schema in emoji.ex:

def changeset(emoji, attrs) do
  emoji
  |> cast(attrs, [:key, :unicode])
  |> validate_required([:key, :unicode])
end

Finally, going to MessageReaction, let's add not-null and uniqueness constraints in the migration:

def change do
  create table(:chat_message_reactions) do
    add :message_id, references(:chat_messages, on_delete: :nothing), null: false
    add :user_id, references(:auth_users, on_delete: :nothing), null: false
    add :emoji_id, references(:chat_emojis, on_delete: :nothing), null: false

    timestamps()
  end

  create index(:chat_message_reactions, [:message_id])
  create index(:chat_message_reactions, [:user_id])
  create index(:chat_message_reactions, [:emoji_id])

  create unique_index(:chat_message_reactions, [:user_id, :message_id, :emoji_id])
end

Let's also modify the schema:

alias CuriousMessenger.Auth.User
alias CuriousMessenger.Chat.Emoji
alias CuriousMessenger.Chat.Message

schema "chat_message_reactions" do
  belongs_to :user, User
  belongs_to :emoji, Emoji
  belongs_to :message, Message

  timestamps()
end

@doc false
def changeset(message_reaction, attrs) do
  message_reaction
  |> cast(attrs, [:user_id, :emoji_id, :message_id])
  |> validate_required([:user_id, :emoji_id, :message_id])
  |> unique_constraint(:emoji_id,
    name: :chat_message_reactions_user_id_message_id_emoji_id_index
  )
end

OK, this was understandably boring - but let's now do mix ecto.migrate and enjoy while Ecto creates your tables!

We'll come back to playing around with our contexts later. Now, if you're using Git, it's a good time to commit your changes - and then let's move on to initial steps in LiveView installation in your project.

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.3.1"}
]
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:

plug :fetch_flash
plug Phoenix.LiveView.Flash

Our common codebase for controllers, views and the router 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

In case you're wondering what the quote blocks mean - do read our earlier Elixir Trickery: Using Macros & Metaprogramming Without Superpowers article to find out!

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.

Thankfully, 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()

It's a good moment to commit the changes in your Git repository if you maintain one.

If you'd like to start at this stage - here is the corresponding revision at our GitHub. Don't forget to watch our repository, and subscribe to us so you don't miss the next episodes!

Your first LiveView

Since Phoenix LiveView is the main concern of this tutorial series, let's try creating our first LiveView-based 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, since we don't have a better way to create those yet - we'll surely make it up in next episodes!

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

Testing it out, and what to do next

We've got the current state of our repository tagged in GitHub, so check it out if you'd just like to test it out.

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.

What's crucially missing, though, is the ability for one LiveView to instantly update the other one with new messages. You'll notice that sending a message in one window doesn't update the other one unless you refresh it. This means it's not very useful at this stage - we'll do much better than this, though, and this will be covered in the next episode of our Modern Talking with Elixir series - don't forget to subscribe if you'd like to learn more!