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.
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 interpolateassigns
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 theUser
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 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.
-
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, 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 aPlug.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).<br> 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
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).
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:
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:
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:<a name="conversation_members_structure"></a>
%{
"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 associatedconversations
), -
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, <a href="https://www.tutorialspoint.com/http/http_overview.htm" target="blank">technically there is not even such a thing as a _connection in HTTP</a>. 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:
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.
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:
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.
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. al 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 interpolateassigns
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 theUser
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 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.
-
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, 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 aPlug.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).<br> 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
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).
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:
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:
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:<a name="conversation_members_structure"></a>
%{
"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 associatedconversations
), -
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, <a href="https://www.tutorialspoint.com/http/http_overview.htm" target="blank">technically there is not even such a thing as a _connection in HTTP</a>. 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:
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.
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:
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.
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.