Improve the ability to conditionally render streams (and lists)

I’ve been enjoying the new Streams API, but I keep needing to work around the lack of access to the underlying items. One issue that consistently comes up is the ability to query if a stream is in an empty state.

Example

Let’s use the LiveBeats application as an example. Currently, when no songs are uploaded, the table of songs renders with no rows. To improve the design, we could implement a call to action that replaces the table, instructing the user on how to upload their first song. This is a common pattern in UI development, where an empty list may have a different treatment than a list of size N > 0.

Limitations of the current design

Ability to query a Stream’s empty state

As of this writing, there is not an API that allows for a user to query if a stream is empty or not. While a user can add additional assigns that count or track of a stream is empty, it poses bookkeeping challenges in a complex application. More importantly, this is trivial to implement with a traditional list, where streams make the ceremony of tracking the empty state much more complex. This is particularly confusing for new users of Elixir and LiveView

DSL for conditionally rendering an empty list

Frameworks/languages such as svelte, surface, and Python implement a for/else idiom. This effectively allows the user to conditionally handle/render an empty list without adding multiple conditionals. This is somewhat of a luxury, but I find it to be a powerful idiom.

Proposal

Considering the challenges outlined above, I propose the following enhancements to the Phoenix Live View library:

1. Addition of a stream_empty?/1 function to the Stream API

I propose introducing a stream_empty?/1 function to the Stream API. This function would return a boolean value representing whether or not the stream is in an empty state. With this new function, users can conveniently query the state of a stream without the need for additional assigns or complex bookkeeping.

API Usage:

<div :if={stream_empty?(@streams.songs)}>
  Click here to upload your first song!
</div>

2. Implementation of a for/else idiom

I suggest extending Phoenix Live View’s template syntax to support a for/else idiom. This would allow developers to conditionally handle and render empty lists and streams directly within their templates, without needing to manage multiple conditionals.

Proposed Syntax:

<%= for item <- @stream.songs do %>
    <!-- Render table -->
<% else %>
    <!-- Render call to action -->
<% end %>

These changes would simplify the process of handling empty states. They would also make the library more accessible for new users by reducing the complexity of common tasks, and enhance its functionality for experienced developers.

4 Likes

I think this is a great idea and would opt for stream_empty?/1 as the second option would require you to utilize the surrounding html tag, IE create a CTA inside of a table.

Haven’t tried this yet myself, but can’t you check if a streams empty like this?

<div :if={@streams.songs == []}>
  Click here to upload your first song!
</div>

Update:

So that doesn’t work, turns out @streams.songs is a %Phoenix.LiveView.LiveStream{} struct.

That said, the %Phoenix.LiveView.LiveStream{} struct partially implements the Enumerable protocol which means you can query the state of the stream with something like this.

<div :if={Enum.count(@streams.songs) == 0}>
  Click here to upload your first song!
</div>
1 Like

Would a non_neg_integer() count field in the LiveStream struct be an acceptable trade-off?

When initializing or resetting the stream set the counter to the list length of items.
When deleting decrement the counter.
When inserting increment the counter.

To clarify my proposal, there are issues that would need to be sorted with implementation.

Without digging into the implementation of streams, the main tradeoffs that it makes are

  1. Not holding the collection in memory by storing inserts and deletes for the next render
  2. On the render, reconcile the insert and delete on the client

Because of #2, LV in its current implementation does not know if an insert is an insert or an update. It also does not know if a delete is a delete or a noop. Due to this limitation, it is not as simple as keeping a counter on the server side.

Likely to implement this feature, a lot of the detection would need to happen on the client side, as the server will not be capable of detecting an empty collection, which would likely negate the benefits of streams in the first place.

That being said, I believe it is worth exploring how this feature could be implemented, as the value is there IMO

See my last post about implementation. You cannot rely on the state of the stream on the client side for any information about the size of the stream. After every render, the stream state is cleared out. This happens in an after render hook that you can see here phoenix_live_view/phoenix_live_view.ex at main · phoenixframework/phoenix_live_view · GitHub

After spending more time thinking about the proposal, I’m not sure that proposal #1 would work, as the server will be incapable of returning the boolean.

This might need to be a special assign on the stream, or make use of special template syntax as suggested in #2

try Enum.count(@stream.songs) == 0

Its working for me

That was my initial thought as well, but as @davydog187 pointed out:

So while Enum.count may appear to work fine at first i.e. after initial mount, it won’t handle situations where a streamed collection gets emptied via deleting or resetting the streams.

1 Like

Tracking stream details has been on my mind from the beginning (stream size, key space, etc), but I’ve been hesitant to do it so far because it requires more state bookkeeping (and thus memory) when folks don’t always need it. That said, we could make it opt-in, but it’s not something I’ve been able to prioritize yet.

The LiveView docs (which I just pushed ahead of 0.19), have an example for infinite scrolling that tracks the “you’ve reached the beginning of time” state in an @end_of_timeline? assign, which is set as the stream is paginated based on whether a next page triggers zero results. So in cases like this, a simple assign that you set programmatically as you stream is all you need.

I can definitely see how something built-in would be useful if all you want to know is if you’ve streamed anything or how many have been streamed. For now I encourage folks to track what state they need on the side. For conditional rendering, a simple assign with an if check is usually all you need, like in the linked example:

<ul
  id="posts"
  phx-update="stream"
  phx-viewport-top={@page > 1 && "prev-page"}
  phx-viewport-bottom={!@end_of_timeline? && "next-page"}
  phx-page-loading
  class={[
    if(@end_of_timeline?, do: "pb-10", else: "pb-[calc(200vh)]"),
    if(@page == 1, do: "pt-10", else: "pt-[calc(200vh)]")
  ]}
>
  <li :for={{id, post} <- @streams.posts} id={id}>
    <.post_card post={post}>
  </li>
</ul>
<div :if={@end_of_timeline?} class="mt-5 text-[50px] text-center">
  🎉 You made it to the beginning of time 🎉
</div>
```
10 Likes

Can we use @streams.posts.inserts == [] to check if a stream is empty or that doesn’t guarantee it’s actually empty?

edit: I guess this is the same case where “You cannot rely on the state of the stream on the client side for any information about the size of the stream. After every render, the stream state is cleared out.”