Phoenix LiveView - Simple Real-Time SPA
Phoenix, Elixir, Single Page App Demo
Here is a quick example of how to create a very simple “real-time”-“single-page-app” using phoenix-liveview. This provides the same functionality to as Realtime Rails with Hotwire - in order to compare.
The repo can be found here: https://github.com/btihen/live-tweets
create / config a project
First we will creat the folder / project location
mkdir tweets
Now we will tell it which software to use:
touch tweets/.tool-versions
cat <<EOF >>tweets/.tool-versions
erlang 23.3.1
elixir 1.11.4-otp-23
nodejs lts-Fermium
Postgres 13.2
EOF
Create a new Phoenix Project
https://carlyleec.medium.com/create-an-elixir-phoenix-app-with-asdf-e918649b4d58
Now you can simply do:
mix phx.new tweets --live
You will now get the message:
The directory /Users/btihen/Dropbox/devel/marpori/tweets already exists. Are you sure you want to continue? [Yn]
Say Y
yes.
Say Y
yes again when you see:
Fetch and install dependencies? [Yn]
This can take a few minutes - when done, enter the directory and setup.
cd tweets
Adjust the DB settings as needed in: config/dev.exs
Create the database and lets see if default tests work and we get the start page.
mix ecto.create
mix test
mix phx.server
assuming all is good lets snapshot:
git init
git add .
git commit -m "initial setup commit"
This code commit can be seen at: https://github.com/btihen/live-tweets/commit/2eb9016371db3210eaf3a1cb35e4066e3b67bdbe
create our tweet model
Create this with the generator (notice we are using mix phx.gen.live
not mix phx.gen.html
):
mix phx.gen.live Messages Post posts body:text
Change migration to require data - add null: false
to our field so it now looks like:
# priv/repo/migrations/20210418084643_create_posts.exs
defmodule Tweets.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts) do
add :body, :text, null: false
timestamps()
end
end
end
Now lets update the routes as described by the generator - in lib/tweets_web/router.ex
so the section that looks like:
scope "/", TweetsWeb do
pipe_through :browser
live "/", PageLive, :index
end
should be change to:
scope "/", TweetsWeb do
pipe_through :browser
# live "/", PageLive, :index
live "/", PostLive.Index, :index
live "/posts", PostLive.Index, :index
live "/posts/new", PostLive.Index, :new
live "/posts/:id/edit", PostLive.Index, :edit
live "/posts/:id", PostLive.Show, :show
live "/posts/:id/show/edit", PostLive.Show, :edit
end
Now check our field body
is required in validations – in our changeset. We see validate_required([:body])
in the file: lib/tweets/messages/post.ex
- so we are all set.
def changeset(post, attrs) do
post
|> cast(attrs, [:body])
|> validate_required([:body])
end
So it time to migrate & test:
mix ecto.migrate
mix test
Hmm - the tests generator and html use different html standards: to make the tests pass test that phoenix returns can't be blank
instead of can't be blank
in test/tweets_web/live/post_live_test.exs
also change: "Welcome to Phoenix!"
to "Listing Posts"
in test/tweets_web/live/page_live_test.exs
Now lets see how our new SPA works:
mix phx.server
It works, but we want to list the most recent tweets at the top of the page – let’s investigate – open lib/tweets_web/live/post_live/index.ex
we see in the mount
command:
# lib/tweets_web/live/post_live/index.ex
def mount(_params, _session, socket) do
{:ok, assign(socket, :posts, list_posts())}
end
It uses list_posts()
to get the list - so let’s change this function.
Open: lib/tweets/messages.ex
and change:
# lib/tweets/messages.ex
def list_posts do
Repo.all(Post)
end
to
# lib/tweets/messages.ex
def list_posts do
Post
|> order_by(desc: :inserted_at)
|> Repo.all
end
Cool now our SPA works like we want – but it isn’t real-time between two users / browsers.
This code can be seen at: https://github.com/btihen/live-tweets/commit/3f432d7c06d974f9c2349937a35e391dedeb2ad6
Broadcast changes with Pubsub
Phoenix uses Websockets to do real-time
communication. In our “context” we will create our channel
- the pipeline that the socket uses to send information back and forth to various “subscribers” - viewers of our page.
Setup the “Messages” Channel
We go into: lib/tweets/messages.ex
and at the top of the file add the Broadcast Setup:
# lib/tweets/messages.ex
defmodule Tweets.Messages do
@moduledoc """
The Messages context.
"""
import Ecto.Query, warn: false
alias Tweets.Repo
alias Tweets.Messages.Post
# Setup Broadcasting
@topic inspect(__MODULE__)
def subscribe do
Phoenix.PubSub.subscribe(Tweets.PubSub, @topic)
end
def notify_subscribers({:ok, post}, event) do
posts = list_posts()
Phoenix.PubSub.broadcast(Tweets.PubSub, @topic, {__MODULE__, event, posts})
{:ok, post}
end
def notify_subscribers({:error, post}, event) do
{:error, post}
end
# Setup Broadcasting
...
Lets quickly review this new code:
@topic inspect(__MODULE__)
makes @topic named Tweets.Messages
- but if we change the module name it changes @topic too.
subscribe
function - allows us to register our index page with channel created automatically by LiveView.
We have two notify_subscribers
because we will call these after we do our DB actions - writing to the DB could fail or succeed. If we have success then we will update all subscribers and the last line tuple passes the results of the interaction back to the actual user. (Of course we don’t need to notify when the DB transaction fails, we only need to pass the message back to the user).
Subscribing to the ‘Messages’ Channel
Now that we have notify_subscribers
that broadcasts Phoenix.PubSub.broadcast(Tweets.PubSub, @topic, {__MODULE__, event, posts})
we need a way to subscribe to this channel and receive these messages in all our index pages.
# lib/tweets_web/live/post_live/index.ex
defmodule TweetsWeb.PostLive.Index do
use TweetsWeb, :live_view
alias Tweets.Messages
alias Tweets.Messages.Post
@impl true
def mount(_params, _session, socket) do
# register with the channel if connection to LiveView
if connected?(socket), do: Messages.subscribe()
{:ok, assign(socket, :posts, list_posts())}
end
@impl true
def handle_info({Messages, "posts-changed", posts}, socket) do
socket = assign(socket, :posts, posts)
{:noreply, socket}
end
...
The important changes are to subscribe to the channel we we have subscribed to our Websocket we do this in the mount
function with if connected?(socket), do: Messages.subscribe()
Now we need a way to recieve information from the channel this is done with the handle_info
function - so we will simply take the new list of posts and update the socket and index will take care of the rest – automatically!
Sending Messages to the Channel
So now to activate our changes - we need to send to the channel via notify_subscribers
when we successfully change something in the Messages “post” context. To do this we will make small changes to the create_post, update_post and delete_post functions. We will add notify_subscribers({status, post}, "posts-changed")
to the end of each function. Since we only defined one event "posts-changed"
in our index page handle_info
function – we will hard-code that into our notify_subscribers
call
So our simple DB calls in Messages currently looks like:
# lib/tweets/messages.ex
def create_post(attrs \\ %{}) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
end
def delete_post(%Post{} = post) do
Repo.delete(post)
end
Now becomes:
# lib/tweets/messages.ex
def create_post(attrs \\ %{}) do
{status, post} = %Post{}
|> Post.changeset(attrs)
|> Repo.insert()
notify_subscribers({status, post}, "posts-changed")
end
def update_post(%Post{} = post, attrs) do
{status, post} = post
|> Post.changeset(attrs)
|> Repo.update()
notify_subscribers({status, post}, "posts-changed")
end
def delete_post(%Post{} = post) do
{status, post} = post
|> Repo.delete()
notify_subscribers({status, post}, "posts-changed")
end
Note: in-order to pass the DB transaction information back to the user, we need to capture that information with the tuple: {status, post}
- which notify_subscribers will pass back and will be returned to the user - the returns values will be either {:ok, post}
or {:error, post_changeset}
Let’s be sure we didn’t break anything and run our tests again:
mix test
Ideally, all is still good so lets try our updated app now:
mix phx.server
Now any changes we make should be seen all users.
Cool, lets snapshot this.
git checkout -b liveview_spa_broadcast_with_pubsub
git add .
git commit -m "add realtime broadcast to all users"
This code can be seen at: https://github.com/btihen/live-tweets/commit/32c179e05cae68c5a2a6d49f54bf5a8dcf4d4dac
Summary
In my mind this is far simpler to setup as a single page app - using the LiveView generator and a little more work to add broadcasting than in Rails. Converting a Standard Phoenix HTML page to LiveView however is considerably more difficult than Converting a Standard Rails page to Hotwire. I also find adding advanced features much more straight-forward in LiveView - as you write the event_handlers in you liveview pages and it is very clear what is happening. In rails you need to know what is happening without being able to see the code. I also like that LiveView - when it can’t find an event - you get lots of errors. This is very helpful.