This dev blog introduces LiveView’s new Streams feature. It lets us elegantly work with large collections of items without keeping them all in memory on the server. Fly.io is a great place to run a Phoenix application! Check out how to get started!
This is the first installment of the Phoenix development blog where we’ll talk about in progress features or day-to-day development updates in between major releases and milestones.
What’s the Problem?
For at least a few years, the Phoenix team has wanted a solution that elegantly addresses large collections of items without requiring the collection to live in memory on the server. We’ve had a hack in place by allowing a container to be marked with phx-update="append"
or phx-update="prepend"
. It worked for some use cases, but it sucked even when it worked. Let’s see why.
Today it works by marking an assign as “temporary”, which means the server throws it away after rendering it. Then to append a new item, we allow the developer to render only the new items, and the client would automagically leave the old ones in place instead of removing them. In practice it looked like this:
def render(assigns) do
~H"""
<div id="users" phx-update="append">
<div :for={user <- @users} id={"user-#{user.id}"}>
<%= user.name %>
</div>
</div>
"""
end
def mount(_, _, socket) do
users = Accounts.list_users()
{:ok, assign(socket, users: users), temporary_assigns: [users: []]
end
def handle_info({:user_added, new_user}) do
{:noreply, assign(socket, users: [new_user])}
end
The append/prepend trick allowed developers to start with a “naive” in-memory store of the collection, then optimize it without changing much of their code. They render the collection in render/1
with a regular for
comprehension, and assign it in the callbacks with regular assign
.
The trick can be seen on line 17, where we re-assign the empty users collection to a single element list with only our new users. When Phoenix LiveView goes to patch the DOM, it will see the parent container is marked with phx-update="append"
and leave the existing children alone, while adding the new ones.
Great! Everyone is happy, except this approach sucked for a number of reasons.
First, deletions were not supported. You’d need to write some JavaScript yourself to remove the DOM elements, and in the case of components you could easily break our own component tracking. Next, the containers only supported two modes of operation: append or prepend. It was not easy to swap the behaviors such as appending a list of posts in a timeline while also prepending new posts on top. Sorting was also not possible. Finally, the internal implementation was expensive and brittle. Before each DOM patch, we had to “fake” the DOM tree to make it look like newly patched items were present in the new tree to ensure morphdom would leave our existing items intact rather than considering them removed.
Enter Streams
We are introducing a new “streams” feature to solve the issues above. New Phoenix 1.7 applications will use streams out of the box for the phx.gen.live
LiveView generators.
Streams bring a new stream
interface while also carrying over the ease of gradual optimization we had before. Streams support dynamic ordering, which makes appending, prepending, or reordering trivial for the developer. Deletes are also just as trivial. Let’s refactor our original example to see how:
def render(assigns) do
~H"""
<div id="users" phx-update="stream">
- <div :for={user <- @users} id={"user-#{user.id}"}>
+ <div :for={{id, user} <- @streams.users} id={id}>
<%= user.name %>
</div>
</div>
"""
end
def mount(_, _, socket) do
users = Accounts.list_users()
- {:ok, assign(socket, users: users), temporary_assigns: [users: []]
+ {:ok, stream(socket, :users, users)}
end
def handle_info({:user_added, new_user}) do
- {:noreply, assign(socket, users: [new_user])}
+ {:noreply, stream_insert(socket, :users, new_user)}
end
In mount/3
, we define a stream with stream/3
. Streams clean up after themselves, so there is no need to mess with temporary assigns yourself. Like before, streams identify their items by DOM id. By default, it will use the item :id
field if the item is a map or struct with such a field. The following two lines are equivalent:
stream(socket, :users, users)
stream(socket, :users, users, dom_id: &"users-#{&1.id}")
Next, in the template in render/1
, we mark the container as phx-update="stream"
, then we use a regular for
comprehension, but with two changes. Streams are placed under a @streams
assign, and when you enumerate a stream you get the computed DOM id along with each item. We then render the DOM id and content as before.
Finally, in handle_info/2
we see the stream interface in action. stream_insert
allows inserting or updating items in the stream. By default, items will be appended on the client, but you can programmatically place them with the :at
option, which mimics the behavior of Elixir’s List.insert_at
. The following two lines are equivalent:
stream_insert(socket, :users, new_user)
stream_insert(socket, :users, new_user, at: -1)
To prepend the new user in the UI instead:
stream_insert(socket, :users, new_user, at: 0)
You can also place the user at an arbitrary index, which makes reordering items in the UI a breeze.
For deletes, stream_delete
works as you’d expect:
stream_delete(socket, :users, user)
Here’s a fully realized example of what streams unlock for LiveView developers. We updated our flagship LiveBeats example app to use streams for its playlist, with drag and drop re-ordering, deletion, and more:
Should streams be used by default now for lists of items?
Streams by default for any kind of collection is a good intuition to have. You should use streams any time you don’t want to hold the list of items in memory – which is most times. Streams are also a goto when you want to efficiently update a single list item without refactoring to a layer of LiveComponents for the items.
Streams Retrospective
There’s something really satisfying about implementing a long-term feature, then shedding all that knowledge by being a user of the feature. LiveView features continue to do this kind of thing to me. I wrote it all – and it still feels like magic when using it!
After playing with the top-level stream API as a user, I am also struck by how simple it is. I constantly wonder “how did it take this long to do this?”, but then you look at the PR. It touched every layer of the LiveView stack – it required features/additions to the HTML engine at the parser level, the diffing engine, the client diff merging, and patches to morphdom.
The best thing about streams is the internal implementation is optimized for both the server and the client. We introduced new features in morphdom to drop all the fake DOM tree hacks from the previous approach.
I’m excited to finally offer a comprehensive solution to an area I was never really satisfied with before. I can’t wait to see what folks ship with this!
Happy hacking!
–Chris