Phoenix LiveView Tutorial: Bootstrap Your Messenger App
To learn even more about how we'll build our Messenger app and get deeper into Phoenix LiveView, subscribe to our newsletter and await further episodes of the series!
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 theUser
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 atitle
, identifying a conversation.ConversationMember
, related to aconversation
and auser
, serving as a registration of a user within a conversation. For each conversation, one user can be itsowner
, who'll be able to e.g. close it.Message
, belonging to aconversation
and auser
who sent it, having acontent
.SeenMessage
, belonging to auser
and amessage
, whosecreated_at
timestamp denotes when the user first displayed the message.Emoji
, having a stringkey
and aunicode
representation, defining an emoji that can be used as a reaction.MessageReaction
, belonging to amessage
,user
andemoji
, 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.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, which I'll explain in a moment.
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
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.
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, andmount/2
has no access to those, we'll just keep it trivial:
def mount(_assigns, socket) do
{:ok, socket}
end
handle_params/3
runs aftermount
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'sassigns
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 forPhoenix.LiveView.Engine
for more details, because it's a really interesting process).
We'll assume that, on every render, the page will contain assigns foruser
, denoting current user,conversation
, including data about current conversation, andmessages
, 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 thephx_submit
attribute we applied to the form inrender/1
? This indicates that, over the WebSocket connection, an event namedsend_message
will be sent. The second argument ofhandle_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).
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!
Check out other articles in this series: