Building a Chat App with LiveView Streams

A Fly bird using a chat application on their computer screen.
Image by Annie Ruygt

Streams are an exciting new feature in Phoenix. Sophie DeBenedetto walks us through creating our own Slack-like chat interface which features infinite scroll back, editing past messages, deleting messages, and appending new messages to the bottom all using Streams. It’s a slick and efficient solution that avoids storing all that message data in the LiveView. Fly.io happens to be a great place to run Phoenix applications. Check out how to get started!

In this post, we’ll build out a LiveView chatroom app with the help of LiveView’s new streams feature. You can follow along in the open source codebase or skip ahead to play around with the finished product. We’ll see how streams seamlessly integrate into your existing live views to power interactive and efficient UIs. Along the way, we’ll look at how streams work under the hood. When we’re done, you’ll have exercised the full functionality of streams and you’ll understand how they work at a deep level.

What are LiveView Streams?

LiveView 0.18.16 ships with the new streams functionality for managing large collections of data client-side, without having to store anything in the LiveView socket. Chris McCord tells us more about this feature and the problem it’s designed to solve in this excellent post.

For the past few years, a question I would often hear from developers interested in LiveView was: “What about large datasets?” Users who needed to display and manage long lists of data had to store that data on the server, or else work with the phx-update="append" feature. Storing large collections server-side can impact performance, while the phx-update="append" feature had its own drawbacks. But, as is so often the case with LiveView over the course of its development, the framework has come to provide a better solution for this commonly expressed concern. Now, you can use streams to efficiently manage large datasets in your live views by detaching that data from the socket and letting the client store it instead of the server.

LiveView exposes an elegant and users-friendly API for storing data in a client-side stream and allowing your app’s users to interact with that data by adding, updating, and deleting items in the stream. We’ll explore this behavior as we build a real-time chat feature into an existing chatroom-style LiveView application. Our chat will even use streams to support an infinite scroll back feature that allows users to view their chat history. Let’s get started.

The StreamChat App

For this project, we have a basic LiveView application set up with the following domain:

  • A Room has many messages.
  • A Message belongs to a room and a sender. A sender is a user.
  • A User has many messages.

Hot Tip! Make sure to grab the project from Github and play with it yourself!

We also have a Chat context that exposes the CRUD functionality for rooms and messages. All of this backs the main LiveView of the application, StreamChatWeb.ChatLive.Root. This LiveView is mapped to the /rooms and /rooms/:id live routes and this is where we’ll be building our stream-backed chatting feature. You can find the starting code for this blog post here, including a seed file that will get you started with some chat rooms, users, and messages. If you’d like to follow along step-by-step with this post, clone down the repo at the start branch. Or, you can check out the completed project on the main branch here.

The starting state for our code-along leaves us with a UI that looks like this:

Screenshot of chat interface with two channels on the left and an empty input with a send button.

A user can navigate to /rooms/:id and see the sidebar that lists the available chatrooms, with the current chatroom highlighted. But we’re not displaying the messages for that room yet. And, while we have the form for a new message, the page doesn’t yet update to reflect that new message in real-time. We’ll use streams to implement both of these features, along with the “edit message” and “delete” message functionality. Let’s get started.

List Messages with Streams

First up, we want to render a list of messages in each chat room. Here’s the UI we’re going for:

Screenshot of chat interface with multiple messages listed from different people.

We’ll use a stream to store the most recent ten messages for the room and we’ll render the contents of that stream in a HEEx template. Let’s start by teaching the ChatLive.Root LiveView to query for the messages and put them in a stream when the /rooms/:id route is requested.

Initialize the Stream

In the router.ex file we have the following route definitions:

live "/rooms", ChatLive.Root, :index
live "/rooms/:id", ChatLive.Root, :show

Note that both the /rooms and /rooms/:id routes map to the same LiveView, ChatLive.Root. The /rooms/:id route is defined with a live action of :show in the socket assigns. The ChatLive.Root LiveView already implements a handle_params/3 callback that queries for the room with the room ID from params and stores the active room in socket assigns. We’ll add some additional code to this callback to fetch the list of messages for the current room and store them in the stream, like this:

def handle_params(%{"id" => id}, _uri, %{assigns: %{live_action: :show}} = socket) do
  {:noreply,
    socket
    |> assign_active_room(id)
    |> assign_active_room_messages()}
end

# single-purpose reducer functions

def assign_active_room(socket, id) do
  assign(socket, :room, Chat.get_room!(id))
end

def assign_active_room_messages(%{assigns: %{room: room}} = socket) do
  stream(socket, :messages, Chat.last_ten_messages_for(room.id))
end

First, we use a single-purpose reducer function to assign the room with the given ID to the socket. Then, we pass that updated socket to another reducer function, assign_active_room_messages/1. That reducer pulls the room out of socket assigns and uses it to fetch the last ten messages. Finally, we create a stream for :messages with a value of this list of messages.

Let’s take a closer look at what happens when we call stream(socket, :messages, Chat.last_ten_messages_for_room(room.id)). Go ahead and pipe the updated socket into an IO.inspect like this:

def assign_active_room_messages(%{assigns: %{room: room}} = socket) do
  stream(socket, :messages, Chat.last_ten_messages_for(room.id))
  |> IO.inspect
end

Let the LiveView reload and you should see the socket inspected into the terminal. Looking closely at the assigns key, you’ll see something like this:

streams: %{
__changed__: MapSet.new([:messages]),
messages: %Phoenix.LiveView.LiveStream{
name: :messages,
dom_id: #Function<3.113057034/1 in Phoenix.LiveView.LiveStream.new/3>,
inserts: [
{"messages-5", -1,
  %StreamChat.Chat.Message{
    __meta__: #Ecto.Schema.Metadata<:loaded, "messages">,
    id: 5,
    content: "Iste cum provident tenetur.",
    room_id: 1,
    room: #Ecto.Association.NotLoaded<association :room is not loaded>,
    sender_id: 8,
    sender: #StreamChat.Accounts.User<
      __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
      id: 8,
      email: "keon@streamchat.io",
      confirmed_at: nil,
      inserted_at: ~N[2023-03-02 01:27:09],
      updated_at: ~N[2023-03-02 01:27:09],
      ...
    >,
    inserted_at: ~N[2023-03-02 01:27:10],
    updated_at: ~N[2023-03-02 01:27:10]
  }},
  # ...
  deletes: []
  }
},
# ...

The call to streams/4 adds a :streams key to socket assigns, which in turn points to a map with a :messages key. The streams.messages assignment contains a Phoenix.LiveView.LiveStream struct that holds all of the info the LiveView client-side code needs to display your stream data on the page.

Notice that the struct has an :inserts key that contains the list of messages we’re inserting into the initial stream. It also contains a :deletes key that is currently empty. All of this data is made available in our template as the @streams.messages assignment.

After the initial render, the list of messages will no longer be present in the socket under streams.messages.inserts. It will be available only to the LiveView client-side code via the HTML on the page. Let’s do that rendering now.

Render Stream Data

We’ll use a function component, Room.show/1, to render the messages list from the root.html.heex template if the @live_action assignment is set to :show. We’ll pass in the messages from the stream when we do so, like this:

# lib/stream_chat_web/live/chat_live/root.html.heex
<Room.show
  :if={@live_action == :show}
  messages={@streams.messages}
  current_user_id={@current_user.id}
  room={@room} />

The Room.show/1 function component will render both the list of messages and a form for a new message. Let’s add in that messages list rendering like this:

defmodule StreamChatWeb.ChatLive.Room do
  use Phoenix.Component
  alias StreamChatWeb.ChatLive.Messages

  def show(assigns) do
    ~H"""
    <div id={"room-#{@room.id}"}>
      <Messages.list_messages messages={@messages} />
      <!-- ... form for a new message -->
    </div>
    """
  end
end

This function component calls another function component, Messages.list/1. This nice, layered UI allows us to wrap up the different concepts on our page into appropriately named functions. Each of these functions can be relatively single-purpose, keeping our code short and sweet and ensuring we have a nice clean location to place our stream rendering code. Let’s take a look at the stream rendering code in Messages.list/1 now.

defmodule StreamChatWeb.ChatLive.Messages do
  use Phoenix.Component

  def list_messages(assigns) do
    ~H"""
    <div id="messages" phx-update="stream">
      <div :for={{dom_id, message} <- @messages} id={dom_id}>
        <.message_meta message={message} />
        <.message_content message={message} />
      </div>
    </div>
    """
  end
end

This is where the magic happens. We create a container div with a unique id of "messages" and a phx-update="stream" attribute. Both of these attributes are required in order for LiveView streams to be rendered and managed correctly. Then, we iterate over the @messages assignment, which we passed in all the way from the root.html.heex template’s call to @streams.messages. At this point, @messages is set equal to the Phoenix.LiveView.LiveStream struct. This struct is enumerable such that when we iterate over it, it will yield tuples describing each item in the :inserts key. The first element of the tuple is the item’s DOM id and the second element is the message struct itself. LiveView uses each item’s DOM id to manage stream items on the page. More on that in a bit.

[Deep Dive: How LiveStream Implements Iteration]

Keep reading if you want a closer look at how LiveStream implements enumeration. Or, skip this section to continue building the chat feature and return here later.

The LiveStream struct implements the Enumerable protocol here which let’s us iterate over it and yield the tuples described above. Here’s a look at one of protocol’s reduce functions:

def reduce(%LiveStream{inserts: inserts}, acc, fun) do
  do_reduce(inserts, acc, fun)
end

You can see that when reduce is called, it pattern matches the inserts out of the function head and passes that list into do_reduce/3. The :inserts key of the stream struct looks something like this:

[
  {"messages-5", -1,
    %StreamChat.Chat.Message{
      __meta__: #Ecto.Schema.Metadata<:loaded, "messages">,
      id: 5,
      content: "Iste cum provident tenetur.",
      room_id: 1,
      room: #Ecto.Association.NotLoaded<association :room is not loaded>,
      sender_id: 8,
      sender: #StreamChat.Accounts.User<
        __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
        id: 8,
        email: "keon@streamchat.io",
        confirmed_at: nil,
        inserted_at: ~N[2023-03-02 01:27:09],
        updated_at: ~N[2023-03-02 01:27:09],
        ...
      >,
      inserted_at: ~N[2023-03-02 01:27:10],
      updated_at: ~N[2023-03-02 01:27:10]
    }},
  # ...
]

It is a list of three-tuples, where the first element is the DOM id, the second element is an instruction to the LiveView client regarding where to position the item in the list (we don’t care about that right now), and the third element is the item itself.

Here’s a simplified look at the version of the do_reduce/3 function that does the heavy lifting:

defp do_reduce([{dom_id, _at, item} | tail], {:cont, acc}, fun) do
  do_reduce(tail, fun.({dom_id, item}, acc), fun)
end

The function ignores the _at element in the tuple, and collects new tuples composed of {dom_id, item}. So, when we iterate a LiveStream struct with a for comprehension, it yields these tuples.

[/Deep Dive]

Let’s inspect this iteration more closely. Go ahead and add this code to the list_messages/1 function and then hop on over to your terminal:

def list_messages(assigns) do
  for {dom_id, message} <- assigns.messages do
    IO.inspect {dom_id, message}
  end
  ~H"""
  # ...
  """
end

You should see something like this:

{"messages-5",
 %StreamChat.Chat.Message{
   __meta__: #Ecto.Schema.Metadata<:loaded, "messages">,
   id: 5,
   content: "Iste cum provident tenetur.",
   room_id: 1,
   room: #Ecto.Association.NotLoaded<association :room is not loaded>,
   sender_id: 8,
   sender: #StreamChat.Accounts.User<
     __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
     id: 8,
     email: "keon@streamchat.io",
     confirmed_at: nil,
     inserted_at: ~N[2023-03-02 01:27:09],
     updated_at: ~N[2023-03-02 01:27:09],
     ...
   >,
   inserted_at: ~N[2023-03-02 01:27:10],
   updated_at: ~N[2023-03-02 01:27:10]
 }
}
# ...

You can see that each tuple has a first element of the DOM id and a second element of the message itself. The DOM id of each element is computed by interpolating the name of the stream, in our case "messages", along with the ID of the item. So, we get a DOM id of "messages-5" and so on.

[Deep Dive] How LiveView computes the stream item DOM id:

Keep reading to take a deep dive into how LiveView computes the DOM id. Or, skip this section to continue building our feature and return to it later.

When you call stream(socket, :messages, message_list), LiveView initializes a new LiveStream struct with the Phoenix.LiveView.LiveStream.new/3 function. That function assigns the struct’s :dom_id attribute to either a function you optionally provide to stream/4, or to the default DOM id function. Here’s a peak at the source code:

 def new(name, items, opts) when is_list(opts) do
  dom_prefix = to_string(name)
  dom_id = Keyword.get_lazy(opts, :dom_id, fn -> &default_id(dom_prefix, &1) end)

  unless is_function(dom_id, 1) do
    raise ArgumentError,
          "stream :dom_id must return a function which accepts each item, got: #{inspect(dom_id)}"
  end

  items_list = for item <- items, do: {dom_id.(item), -1, item}

  %LiveStream{
    name: name,
    dom_id: dom_id,
    inserts: items_list,
    deletes: [],
  }
end

It creates a variable, dom_prefix by stringifying the name of the stream–in our case :messages. Then, it sets dom_id either to a function you pass into stream/4 like this: stream(:messages, messages, &myFunc), or to an anonymous function that wraps the default_id/2 function. Let’s peek at the default_id/2 function now:

defp default_id(dom_prefix, %{id: id} = _struct_or_map), do: dom_prefix <> "-#{to_string(id)}"

The function is pretty straightforward, it returns a string that prepends the dom_prefix to the stringified item id.

As you can see above, the dom_id function is then called when LiveStream.new iterates over the list of items to be inserted:

items_list = for item <- items, do: {dom_id.(item), -1, item}

For each item in the list, this iteration creates a three-tuple where the first element is the result of invoking the dom_id function for the given item. So, we end up with tuples in which the first element is something like "messages-52", and so on.

Takeway? Digging into the streams code we find it isn’t mystical or scary. We shouldn’t be afraid to peek inside the libraries we depend on.

[/Deep Dive]

LiveView uses the DOM id of each stream item to track that item and allow us to edit and delete the item. LiveView needs this DOM id to be attached to the HTML element that contains the stream item because stream data is not stored in socket assigns after the initial render. So, LiveView must be able to derive all the information it needs about the item and its position in the stream from the rendered HTML itself.

We attach the DOM id to each div produced by the iteration in our :for directive. Here’s another look at that code:

<div :for={{dom_id, message} <- @messages} id={dom_id}>
  <.message_meta message={message} />
  <.message_content message={message} />
</div>

That’s all we need to do to render the list of messages from the stream. We stored the initial stream in socket assigns, iterated over it, and rendered it using the required HTML structure and attributes. Now, the page will render with this list of messages from the stream, and the ChatLive.Root LiveView will no longer hold this list of messages in the streams.messages socket assigns. After the initial render, socket.assigns.streams.messages looks like this:

streams: %{
  __changed__: MapSet.new([:messages]),
  messages: %Phoenix.LiveView.LiveStream{
  name: :messages,
  dom_id: #Function<3.113057034/1 in Phoenix.LiveView.LiveStream.new/3>,
  inserts: [],
  deletes: []
}

We’ll see LiveView’s stream updating capabilities in action in the next section. Next up, we’ll build the infinite scroll back feature that loads the previous chat history as the user scrolls the chat window up. Each time the user scrolls up and hits the top of the chat window, we’ll prepend an older batch of messages to the stream. You’ll see that LiveView handles the work of how and where to prepend those messages on the page. All we have to do is tell LiveView that an item should be prepended to the stream, and the framework takes care of the rest. Let’s do that now.

Prepend Stream Messages for Infinite Scroll Back

Our app uses a JS hook to send the "load_more" event to the server when the user scrolls up to the top of the chat window. You can check out the hook implementation here. We won’t get into the details of this JavaScript now though. Just note this line that pushes the "load_more" event. Now all you need to do is add a new div with the hook attached to the messages display, like this:

# lib/stream_chat_web/live/chat_live/messages.ex
def list_messages(assigns) do
  ~H"""
  <div id="messages" phx-update="stream">
    <div id="infinite-scroll-marker" phx-hook="InfiniteScroll"></div> <!-- add me! -->
    <div :for={{dom_id, message} <- @messages} id={dom_id}>
      <.message_meta message={message} />
      <.message_content message={message} />
    </div>
  </div>
  """
end

Now we’re ready to handle the "load_more" event in our LiveView by prepending items to the stream.

Prepend Stream Items

In the ChatLive.Root LiveView, we need an event handler to match the "load_more" event. Go ahead and implement the function definition like this:

# lib/stream_chat_web/live/chat_live/root.ex
def handle_event("load_more", _params, socket) do
  # coming soon!
end

Our event handler needs to fetch the previous batch of messages from the database and prepend each of those messages to the stream. We do have a context function available to us to query for n messages older than a given ID: Chat.get_previous_n_messages/2, but we have one problem. Since LiveView does not store stream data in the socket, we have no way of knowing what the ID of the currently loaded oldest message is. So, we can’t query for messages older than that one. We need to store awareness of this “oldest message” ID in the socket. Let’s fix that now and then we’ll return to our event handler.

When do we have access to the oldest message in the stream? When we query for the messages to add to the initial stream in our handle_params/3 callback. At that time, we should grab the oldest message and store its ID in socket assigns. Here’s our updated handle_params function:

# lib/stream_chat_web/live/chat_live/root.ex
def handle_params(%{"id" => id}, _uri, %{assigns: %{live_action: :show}} = socket) do
  messages = Chat.last_ten_messages_for(socket.assigns.room.id)
  {:noreply,
    socket
    |> assign_active_room(id)
    |> assign_active_room_messages(messages)
    |> assign_oldest_message_id(List.first(messages))}
end

# ...

def assign_active_room_messages(socket, messages) do
  stream(:messages, messages)
end

def assign_oldest_message_id(socket, message) do
  assign(socket, :oldest_message_id, message.id)
end

Now we can use the oldest message ID in socket assigns to query for the previous batch of messages. Let’s do that in our event handler now.

# lib/stream_chat_web/live/chat_live/root.ex
def handle_event("load_more", _params, %{assigns: %{oldest_message_id: id}} = socket) do
  messages = Chat.get_previous_n_messages(id, 5)

  {:noreply,
    socket
    |> stream_batch_insert(:messages, messages, at: 0)
    |> assign_oldest_message_id(List.last(messages))}
end

We query for the previous five messages that are older than the current oldest message. Then, we insert this batch of five messages into the stream. Finally, we assign a new oldest message ID.

Let’s take a closer look at the stream_batch_insert function now. This is a hand-rolled function since the streams API doesn’t currently support a “batch insert” feature. You’ll find it in the live_view behaviour implement in our app’s StreamChatWeb module. I’ve placed it here because I feel that this function should be highly reuseable within the application, and I’d even like to see LiveView streams offer some such functionality in future release.

Let’s take a look at the stream_batch_insert/4 function now:

# lib/stream_chat_web.ex
def stream_batch_insert(socket, key, items, opts \\ %{}) do
  items
  |> Enum.reduce(socket, fn item, socket ->
    stream_insert(socket, key, item, opts)
  end)
end

Here, we iterate over the items with Enum.reduce using the socket as an accumulator. For each item, we insert it into the stream. In our event handler, we call stream_batch_insert with opts of at: 0. This option is passed to stream_insert/4 for each item. As a result, we end up with a socket assigns with the following insertion instructions for LiveView:

streams: %{
  __changed__: MapSet.new([:messages]),
  messages: %Phoenix.LiveView.LiveStream{
    name: :messages,
    dom_id: #Function<3.113057034/1 in Phoenix.LiveView.LiveStream.new/3>,
    inserts: [
      {"messages-111", 0,
        %StreamChat.Chat.Message{
          id: 111,
          content: "10",
          #...
        }},
      {"messages-110", 0,
        %StreamChat.Chat.Message{
          id: 110,
          content: "9",
          # ...
      }},
    ],
    deletes: []
  }
}
# ...

Notice that the second element of each tuple in the :inserts collection is 0. This tells LiveView to insert these items at the beginning of the stream on the page. When the page re-renders, it will display these five older messages in the correct order, at the top of the chat messages display. Here’s what our feature looks like in action:

Now that we’ve built out our infinite scroll back feature and seen how streams work to prepend new data, we’ll take a look at the form for a new message, and use streams to append new messages to the end of the messages list.

Append a New Message with stream_insert

We’re already rendering the form for a new message in the Room.show/1 function component like this:

# lib/stream_chat_web/live/chat_live/room.ex
def show(assigns) do
  ~H"""
  <div id={"room-#{@room.id}"}>
    <Messages.list_messages messages={@messages} />
    <.live_component
      module={Message.Form}
      room_id={@room.id}
      sender_id={@current_user_id}
      id={"room-#{@room.id}-message-form"}
    />
  </div>
  """
end

When that form is submitted, it triggers an event handler implemented in the form live component that calls Chat.create_message/1. So, when the user submits the form, a new chat message is created. But that new message isn’t added to the page in real-time. The user would have to refresh the page to see the latest message.

We’re ready to teach our LiveView to insert the new message once it’s created. This is the responsibility of the ChatLive.Root LiveView, since that is the LiveView that has awareness of the @streams.messages assignment. Luckily for us, our chat feature is already backed by PubSub for real-time capabilities. The Chat.create_message/1 function broadcasts an event when a new message is created, like this:

Endpoint.broadcast(
  "room:#{message.room_id}",
  "new_message",
  %{message: message})

We just need to tell our ChatLive.Root LiveView to subscribe to the PubSub topic for the active room. We’ll do that in handle_params/3:

def handle_params(%{"id" => id}, _uri, %{assigns: %{live_action: :show}} = socket) do
  if connected?(socket), do: Endpoint.subscribe("room:#{id}")

  # ...
end

Now, when a new message is created, any ChatLive.Root LiveView processes for that message’s room will receive a PubSub event. The handle_info/3 for this event is where we’ll insert the new chat message into the stream. Let’s build that now.

Append a Stream Item

Add the following handle_info/3 function to ChatLive.Root:

# lib/stream_chat_web/chat_live/root.ex
def handle_info(%{event: "new_message", payload: %{message: message}}, socket) do
  {:noreply, insert_new_message(socket, message)}
end

def insert_new_message(socket, message) do
  socket
  |> stream_insert(:messages, Chat.preload_message_sender(message))
end

This time around, we call stream_insert/4 with no additional options. In this case, the resulting LiveStream struct in socket assigns will look something like this:

streams: %{
  __changed__: MapSet.new([:messages]),
  messages: %Phoenix.LiveView.LiveStream{
    name: :messages,
    dom_id: #Function<3.113057034/1 in Phoenix.LiveView.LiveStream.new/3>,
    inserts: [
      {"messages-111", -1,
        %StreamChat.Chat.Message{
          id: 111,
          content: "10",
          #...
        }
      },
    ],
    deletes: []
  }
}
# ...

Once again, we have a LiveStream struct with the :inserts key populated with the list of inserts. Now we have just one item in the list. The tuple representing that item has a second element of -1. This tells LiveView to append the new item to the end of the stream. As a result, the new message will be rendered at the end of the list of messages on the page.

That’s it for our new message feature. Once again, streams did the heavy lifting for us. All we had to do was tell LiveView that a new item needed to appended. Now we’re ready to build the edit message feature and take a look at how to update items in a stream. Then, we’ll wrap up with our delete message feature.

Update an Existing Message with stream_insert

The edit message form lives in the ChatLive.Message.EditForm live component, which is contained in a modal that we show or hide based on user interactions. This form behaves similarly to the form for a new message. It’s "save" event handler calls the Chat.update_message context function, which emits an "updated_message" event over PubSub.

We’ll implement a handle_info/3 for this event in the ChatLive.Root LiveView, since that LiveView is responsible for managing the @streams.messages assigns. Let’s do that now.

# lib/stream_chat_web/chat_live/root.ex
def handle_info(%{event: "updated_message", payload: %{message: message}}, socket) do
  {:noreply,
    socket
    |> insert_updated_message(message)}
end

def insert_updated_message(socket, message) do
  socket
  |> stream_insert(:messages, Chat.preload_message_sender(message), at: -1)
end

Here, we call stream_insert yet again, this time with the updated message and the at: -1 option. Since we’re passing a message that the stream is already tracking on the page, LiveView will know to update the existing message item in the stream. The at: -1 option tells LiveView to update the item at its current stream location, rather than appending it to the end of the list. Now, the page will re-render and display the updated in message in place, like this:

Before we wrap up, we need to build out the message delete feature. Let’s do that now.

Delete a Message with stream_delete

We render a delete icon for each message when the message is hovered over, like this:

Screenshot shows the hover delete button link for deleting a message.

When the user clicks that button, we send a "delete_message" event to the LiveView. Let’s handle that event now by deleting the message from the stream.

# lib/stream_chat_web/chat_live/root.ex
def handle_event("delete_message", %{"item_id" => message_id}, socket) do
  {:noreply, delete_message(socket, message_id)}
end

def delete_message(socket, message_id) do
  message = Chat.get_message!(message_id)
  Chat.delete_message(message)
  stream_delete(socket, :messages, message)
end

We query for the message to be deleted, execute a call to delete that message from the database, and then tell the stream to delete the message from its list. The call to steam_delete returns a socket with an assigns that looks something like this:

streams: %{
  __changed__: MapSet.new([:messages]),
  messages: %Phoenix.LiveView.LiveStream{
    name: :messages,
    dom_id: #Function<3.113057034/1 in Phoenix.LiveView.LiveStream.new/3>,
    inserts: [],
    deletes: ["messages-20"]
  }
}

Notice that :inserts is empty, but :deletes contains a list with the DOM ID of the item to be deleted. This instructs LiveView to remove the item with that DOM ID from the rendered list of @streams.messages. If you pass a struct to stream_delete, LiveView will compute the DOM ID to be deleted. Alternatively, if you don’t have access to that struct or don’t want to query for it, you can give stream_delete a third argument of the DOM ID directly, either by re-computing it yourself or invoking the stream’s dom_id/2 function stored in @streams.messages.dom_id.

That’s all we need to do to support our delete message functionality. Once we tell LiveView that there is a stream item to delete, the framework once again takes care of the rest. It re-renders the page, triggering LiveView JS framework code that removes the specified item from the rendered list of @streams.messages.

Okay, we’ve covered a lot of ground. Let’s wrap up.

Wrap Up

LiveView’s new streams feature packs a powerful punch! It allows us to build and manage large datasets client-side, while writing very little custom code. True to the declarative nature of LiveView, the streams API asks you to provide LiveView with some basic instructions regarding what data to manage in the stream and what to do with that data based on certain user interactions. You don’t have to tell LiveView how to render stream data on the page or how to prepend, append, update, or delete items from that data collection.

Our interactive, real-time chatting feature successfully uses streams to manage chat messages fully on the client, and we had to write only a few lines of streams-specific code to make it happen. Client-side data management with streams opens up a whole new set of possibilities for LiveView developers to efficiently manage large data collections, and I’m excited to see what you build with it next.

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!