Subscribe to access all episodes. View plans →

#159: Phoenix LiveView Streams

Published March 6, 2023

Phoenix 1.7

Phoenix LiveView 0.18.3

LiveView.stream/4

Follow along with the episode starter on GitHub


Phoenix LiveView streams are a great feature that allows you to manage large collections of data on the client without having to keep those resources in memory on your server. And as of Phoenix 1.7 streams will be used with the phx.gen.live generators.

In this episode let’s update an existing Phoenix LiveView application to use LiveView streams. The application we’ll be updating is this one that lists different albums. We’ll update the LiveView that renders this album table to use LiveView streams.

The application here has been updated to Phoenix 1.7 with a LiveView version that supports streams. Before you begin double-check that the version of LiveView your application uses supports streams.

Alright, let’s open our editor and go to the album_live/index.ex live view. To start we’ll want to go to the mount function and assign a new stream to the socket, which we can do with the Phoenix LiveView stream/4 function. It will take the socket the name of our stream, let’s call ours albums, and then an enumerable for the initial insert on the page. For our example, this will be our albums, which we can return with the list_albums function. We can also provide the option of dom_id - this is used to generate each item’s DOM id. By default, it will use this format - the ID of our album, prefixed with the hyphenated name of our stream. This is useful if your collection doesn’t have an ID - you’ll need to specify the format of the DOM id to use. Let’s keep things simple and use the default here.

lib/teacher_web/live/album_live/index.ex

...

@impl true
def mount(_params, _session, socket) do
  {:ok, stream(socket, :albums, list_albums())}
end

...

Alright, now that we’ve assigned our stream to the socket, let’s update our template to use it. We’ll go to the “album_live/index.html.heex” template. And we’ll need to update the parent DOM container - in this case, our <tbody> to be used with streams.

It needs to have a unique ID - which we already have here. But we will need to add the phx-update="stream" attribute to it. Then we’ll need to update how we render our table rows. Our albums are now in the @streams assign under the name albums, so let’s access them by that. Then the DOM id and album will be passed in as a tuple, so we can easily use them when rendering our data.

Now that we have our dom_id let’s take it and update our table row to use it. What we have here will work, but there’s actually a shorter way to write this. HEEX supports some special attributes, including :for.

Let’s rewrite this to use the :for in our <tr> (element), which will render a <tr> for each album in our stream. With that, we can remove our previous comprehension.

Template path: lib/teacher_web/live/album_live/index.html.heex

...
<tbody id="albums" phx-update="stream">
    <tr :for={{dom_id, album} <- @streams.albums} id={dom_id}>
      <td><%= album.title %></td>
      <td><%= album.artist %></td>
      <td><%= album.summary %></td>
      <td><%= album.year %></td>

      <td>
        <span><%= live_redirect "Show", to: Routes.album_show_path(@socket, :show, album) %></span>
        <span><%= live_patch "Edit", to: Routes.album_index_path(@socket, :edit, album) %></span>
        <span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: album.id, data: [confirm: "Are you sure?"] %></span>
      </td>
    </tr>
</tbody>
...

Now let’s test that our albums are loading using our new stream method. We’ll go to command line and start our server.

$ mix phx.server
...

If we view our albums in the browser - great - they’re there. Let’s inspect the page and we see our table body with phx-update="stream" along with the expected DOM id for each <tr>. This is great, now let’s update the album actions to use stream functions starting with updating the album “Delete” action to use stream_delete/3.

We’ll go back to our “album_live/index.ex” live view and in the handle_event ”delete” callback instead of getting all the albums with list_albums after the album was deleted and then updating the socket with them, we’ll use stream_delete passing in the socket. The name of the stream to remove it from, in this case :albums, and then the album to remove.

lib/teacher_web/live/album_live/index.ex

...

@impl true
def handle_event("delete", %{"id" => id}, socket) do
  album = Recordings.get_album!(id)
  {:ok, _} = Recordings.delete_album(album)

  {:noreply, stream_delete(socket, :albums, album)}
end

...

If we go back to the browser…and remove an album. Perfect - it works - our album was removed from our table here. Now let’s update our last two actions - adding and editing albums.

We’ll use the stream_insert/4 to handle both of these actions. When an album is updated or added, we are handling it here in the FormComponent. But we have an issue - we can’t stream our changes directly from our FormComponent here. Instead what we’ll do is follow the pattern outlined in the “Streams” section of the official Phoenix Framework’s blog post. We’ll send a message to the parent LiveView notifying it that the album has been added or updated and then the LiveView will stream the update to the page.

In our FormComponent let’s create a new private function named notify_parent_live_view that will take a message. Then we’ll use send to pass that message along to our parent live view. Now we just need to call our new function whenever an album is added or updated. Let’s go to our first save_album function for when an album is updated. We’ll need to use our album, so let’s remove the underscore from it. Then let’s call notify_parent_live_view with a message that’s a two-element tuple with the atom :album_updated and then the album. Currently, our code is using push_redirect - this is used to navigate to another LiveView. Instead, we’ll want to use push_patch which is used for navigation within the current LiveView. Let’s make the same changes to our other save_album function. We’ll stop ignoring the created album then we’ll call notify_parent_live_view only we’ll use :album_added in our message tuple here. Then we’ll update it to use push_patch.

lib/teacher_web/live/album_live/form_component.ex

...

defp notify_parent_live_view(msg) do
  send(self(), msg)
end

defp save_album(socket, :edit, album_params) do
  case Recordings.update_album(socket.assigns.album, album_params) do
    {:ok, album} ->
      notify_parent_live_view({:album_updated, album})
      {:noreply,
       socket
       |> put_flash(:info, "Album updated successfully")
       |> push_patch(to: socket.assigns.return_to)}

    {:error, %Ecto.Changeset{} = changeset} ->
      ...
      
  end

end

defp save_album(socket, :new, album_params) do
  case Recordings.create_album(album_params) do
    {:ok, album} ->
      notify_parent_live_view({:album_added, album})
      {:noreply,
       socket
       |> put_flash(:info, "Album created successfully")
       |> push_patch(to: socket.assigns.return_to)}

    {:error, %Ecto.Changeset{} = changeset} ->
      ...
      
  end

end

...

Now that we’re sending a notification to the parent LiveView, we need to update it to handle our notification. Let’s go back to our “album_live/index.ex” live view and we’ll add a handle_info callback, pattern matching on {:album_updated, album}. Inside the callback we’ll return a :noreply tuple, calling stream_insert to update our albums stream with our updated album.

Then let’s add another handle_info callback, this time pattern matching on the {:album_added, album} message tuple, and inside it, we’ll do the same thing - return a :noreply tuple, calling stream_insert to update our albums stream with our new album.

lib/teacher_web/live/album_live/index.ex

...

@impl true
def handle_info({:album_updated, album}, socket) do
  {:noreply, stream_insert(socket, :albums, album)}
end

def handle_info({:album_added, album}, socket) do
  {:noreply, stream_insert(socket, :albums, album)}
end

...

Let’s go back to the browser and add an album. Now when we create this album, we want it to appear at the top of our table since it’s sorted by the updated_at timestamp. It was created, but it’s not appearing at the top of the table. Instead, it’s being appended to our table. Because this is sorted by the updated_at column and if we refresh the page - it’s now appearing at the top of the table. We need to specify the placement of our album when it’s added.

Let’s go back to the stream_insert functions that are called and we can include the :at option to specify the index we want our updated album to be inserted at. Since we want this to be at the top of the table whenever an album is added or updated, we’ll use at: 0 for both cases. It’s worth mentioning again here that both adding or editing an album using the same stream_insert function and we could easily condense these two function callbacks into one.

lib/teacher_web/live/album_live/index.ex

...

@impl true
def handle_info({:album_updated, album}, socket) do
  {:noreply, stream_insert(socket, :albums, album, at: 0)}
end

def handle_info({:album_added, album}, socket) do
  {:noreply, stream_insert(socket, :albums, album, at: 0)}
end

...

Now if we go back to our page and now when we try to update an album. Perfect - it’s updated and displays at the top of the table. Our albums are now updated to use the Phoenix LiveView streams.

© 2024 HEXMONSTER LLC