It’s now possible to try the pushState support available in Phoenix LiveView (which, remember, is still in beta)
What is pushState and why can be useful?
With Phoenix LiveView we can easily update parts of the page without having to change the location, refreshing the page or directly use JavaScript.
But when the page’s content changes, the URL remains the same, making it difficult for a user to bookmark or share the current page state.
The Phoenix LiveView pushstate support (with live_link/2
and live_redirect/2)
solves exactly this problem. While changing the page content we can now dynamically update the URL without a page refresh.
The reason why it’s called pushState
is because it refers to the history.pushState
function in the HTML5 History API, which gives us the ability to change the URL and push a new page in the browser’s history.
In this article we are going to test this functionality in Phoenix LiveView, with two different examples:
The first is a LiveView which shows picture thumbnails taken from unsplash.com. When we click on a thumbnail, the full picture is shown in the same page. We’ll see how to use the LiveView pushState support to update the URL, making easy to share one specific picture.
In the second example we’ll see something different, an animated URL with emojis. Maybe something we’re not going to use in a real app, but something fun to build.
You can find these examples in the poeticoding/phoenix_live_view_example GitHub repo, which is a fork of the original chrismccord/phoenix_live_view_example.
If you haven’t tried Phoenix LiveView yet, subscribe to the newsletter to receive new Elixir and Phoenix content. New LiveView introductory articles and screencasts are coming soon!
LiveView Pictures page
In this example we build a simple LiveView page where we show a list of pictures thumbnails taken from Unsplash. When we click on the thumbnail, the full picture is shown in the page and the URL is updated to something that uniquely refers to that specific picture.
First, we add the live route in lib/demo_web/router.ex
defmodule DemoWeb.Router do
scope "/", DemoWeb do
live "/pictures", PicturesLive
end
end
and then we create the file lib/demo_web/live/pictures_live.ex file, where we define the new DemoWeb.PicturesLive
LiveView module.
defmodule DemoWeb.PicturesLive do
use Phoenix.LiveView
alias DemoWeb.Router.Helpers, as: Routes
@pictures %{
"ySMOWp3oBZk" => %{
author: "Ludomił",
img: "https://images.unsplash.com/photo-..."
},
...
}
def render(assigns) do
pictures = @pictures
~L"""
<div class="row">
<%= for {id, pic} <- pictures do %>
<div class="column"
phx-click="show" phx-value="<%= id %>">
<%= pic.author %>
<img src="<%= picture_url(pic.img, :thumb) %>">
</div>
<% end %>
</div>
<%= if @selected_picture do %>
<hr>
<center>
<label><%= @selected_picture.author %></label>
<img src="<%= picture_url(@selected_picture.img, :big) %>">
</center>
<% end %>
"""
end
def mount(_session, socket) do
socket = assign(socket, :selected_picture, nil)
{:ok, socket}
end
def handle_event("show", id, socket) do
picture = @pictures[id]
{:noreply, assign(socket, :selected_picture, picture)}
end
defp picture_url(img, :thumb),
do: "#{img}?w=250fit=crop"
defp picture_url(img, :big),
do: "#{img}?w=800&h=500fit=crop"
end
For simplicity we use a Map @pictures
, where the keys are the picture IDs and the values are maps with :img
URL and :author
name.
At the bottom we find a multi-clause function picture_url/2
, which we use to get the thumbnail and large image URL by appending w
, h
and fit
parameters to the img
URL string.
In the render/1
function we loop through the pictures
map, showing the thumbnails and making them clickable.
def render(assigns) do
pictures = @pictures
~L"""
<div class="row">
<%= for {id, pic} <- pictures do %>
<div class="column"
phx-click="show" phx-value="<%= id %>">
<%= pic.author %>
<img src="<%= picture_url(pic.img, :thumb) %>">
</div>
<% end %>
...
Since pictures
is a map, in the generator we pattern match both the key and value {key, value} <- map
.
For each picture we show its thumbnail using the picture_url(pic.img, :thumb)
function, which returns the thumbnail URL.
With phx-click="show"
we make the div
with author name and image clickable. It means that when the user clicks the element, a "show"
event is sent to the LiveView process, along with the phx-value
value.
This event is handled by the handle_event
function.
def handle_event("show", id, socket) do
picture = @pictures[id]
{:noreply, assign(socket, :selected_picture, picture)}
end
The id
argument is the value passes with phx-value
HTML attribute. In this function we use the id
to get the picture map and assign
it to :selected_picture
.
After clicking the picture thumbnail, LiveView re-renders the view. This time the @selected_picture
is bound to a picture map (which initially is set to nil
in the mount
function), so LiveView renders the HTML with the :big
image.
It works, but as we said before, it doesn’t change the URL, making it difficult to share the page state.
live_redirect
to change the URL using pushState
Let’s see now how to change the URL without refreshing the page.
We start by adding a new route in lib/demo_web/router.ex
defmodule DemoWeb.Router do
scope "/", DemoWeb do
live "/pictures", PicturesLive
live "/pictures/:id", PicturesLive
end
end
The new route with :id
is triggered by passing the picture id in the URL – the case where a picture is selected.
Back to our PictureLive module, we now have to add the handle_params/3
function.
def handle_params(%{"id" => id}=_params, _uri, socket) do
picture = @pictures[id]
{:noreply, assign(socket, :selected_picture, picture)}
end
In this function we do exactly what we were doing when handling the show event. The handle_params
is invoked just after mount/2
, when the user loads the page, and when changing the URL with live_link/2
and live_redirect/2
.
To handle the /pictures
route, we add below another handle_params
clause, where we set the :selected_picture
to nil
.
def handle_params(%{"id" => _uri, socket) do ... end
# catchall
def handle_params(_, _uri, socket) do
{:noreply, assign(socket, :selected_picture, nil)}
end
We now update the handle_event/3
function
def handle_event("show", id, socket) do
{:noreply,
live_redirect(
socket,
to: Routes.live_path(socket, DemoWeb.PicturesLive, id)
)
}
end
Instead of assigning the picture (like we did before), we use live_redirect/2
which changes the URL to /pictures/:id
(using pushState). handle_params(%{"id" => id}, _uri, _socket)
is then called, using the id
to assign the selected_picture
.
There is a better way to implement this specific example with live_link/2
– take a look at Phoenix LiveView live_link
Animated URL 🌝
Let’s see now how to create an animation in the address bar, with LiveView and emoji.
As we did for the previous example, we add the two routes lib/demo_web/router.ex
defmodule DemoWeb.Router do
scope "/", DemoWeb do
live "/moon", MoonLive
live "/moon/:moon", MoonLive
end
end
And we define the MoonLive
module in lib/demo_web/live/moon_live.ex
defmodule DemoWeb.MoonLive do
use Phoenix.LiveView
alias DemoWeb.Router.Helpers, as: Routes
@moons ["🌑", "🌒", "🌓", "🌔", "🌝", "🌖", "🌗", "🌘"]
@moons_count Enum.count(@moons)
def render(assigns) do
~L"""
<button phx-click="start">start</button>
<button phx-click="stop">stop</button>
"""
end
def mount(_session, socket) do
{:ok, socket}
end
def handle_params(_, _uri, socket) do
{:noreply, socket}
end
def handle_event("start", _, socket) do
socket =
socket
|> assign(:moon_idx, 0)
|> assign(:running, true)
Process.send_after(self(), "next_moon", 100)
{:noreply, socket}
end
def handle_event("stop", _, socket) do
{:noreply, assign(socket, :running, false)}
end
def handle_info("next_moon", socket) do
idx = rem(socket.assigns.moon_idx, @moons_count)
moon = Enum.at(@moons, idx)
socket = assign(socket, :moon_idx, idx + 1)
if socket.assigns.running,
do: Process.send_after(self(), "next_moon", 100)
{:noreply,
live_redirect(socket,
to: Routes.live_path(socket, DemoWeb.MoonLive, moon),
replace: true)}
end
end
@moons
is a list with 8 frames ["🌑", "🌒", "🌓", "🌔", "🌝", "🌖", "🌗", "🌘"]
we are going to use in the animation, each one is an emoji.
render/1
The render
is pretty simple: we just have two buttons, start and stop. Each one sends and event to the LiveView process.
handle_event(“start”, _, socket)
Clicking the start button, we send a start event to the LiveView process. This event is handled by handle_event("start", _, socket)
which sets :running
to true
and initialize the :moon_idx
to 0
. We will use this :moon_idx
to know at which frame of the @moons
list we are.
With Process.send_after(self(), "next_moon", 100)
we send a delayed message (100ms) to the current LiveView process (self()
), starting the animation.
handle_info(“next_moon”, socket)
The next_moon message is processed by handle_info("next_moon", socket)
.
In this function we get the right moon frame
idx = rem(socket.assigns.moon_idx, @moons_count)
moon = Enum.at(@moons, idx)
We increase the :moon_idx
socket = assign(socket, :moon_idx, idx + 1)
We check if :running
is still true
and we continue the animation sending another delayed "next_moon"
message, which will be handled by the same function
if socket.assigns.running,
do: Process.send_after(self(), "next_moon", 100)
And like we did in the previous example, we use live_redirect/2
to update the URL. We pass moon
which is the string with the emoji we want to show on the address bar
{:noreply,
live_redirect(socket,
to: Routes.live_path(socket, DemoWeb.MoonLive, moon),
replace: true)
}
With the replace: true
option we change the current url without polluting the browser’s history.
handle_event(“stop”, _, socket)
Clicking the Stop button, we send a stop event and the handle_event("stop", _, socket)
function sets :running
to false
, stopping the animation.
def handle_event("stop", _, socket) do
{:noreply, assign(socket, :running, false)}
end