Phoenix LiveView – Change the URL without refreshing the page

It’s now possible to try the pushState support available in Phoenix LiveView (which, remember, is still in beta)

Add live_link and live_redirect for push state support
Add live_link and live_redirect for push state support

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.

HTML5 history API - pushState
HTML5 history API – pushState

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.

Phoenix LiveView Pictures example with pushstate
Phoenix LiveView Pictures example with pushstate

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.

LiveView animated URL
LiveView animated URL

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.

Pictures without pushState
Pictures without pushState

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

Pictures - LiveView pushstate
Pictures – LiveView pushstate

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
Animated URL with moon emoji