How to handle stream with pagination implementation like scrivener, flop?

Hi, a beginner here.

My questions:

  1. why phoenix / ecto doesn’t have internal implementation pagination ?
  2. how to handle stream with pagination implementation like scrivener, flop ?

thanks alot.

I just experimented a bit with streams and flop. I opened a draft PR here that makes some changes to the table component in order to support streams. The crucial part of the implementation is actually the update of the stream when the view is paginated, so I’ll re-post the (edited) comment I added to the PR here for discussion.

Since we need to update the existing rows when we paginate, the DOM ID is based on the table row number (generated with Enum.with_index/2), and not on the ID of the item. When the page is changed, the new items take up the spots of the previous page. If the result count is smaller than the page count, the extraneous items are removed by referencing the row numbers.

Dynamically adding and removing items in a paginated view can be tricky, especially if page-based pagination is used.

At least in cursor-based pagination, items can be safely removed from the view, but since this implementation bases the DOM ID on a row number, and not on an attribute of the item, the row_item needs to be changed to fn {dom_id, {pet, _}} -> {dom_id, pet} end, so that the DOM ID can be added to the delete link as a phx-value attribute.

In all other cases, it is probably better to just re-fetch the items for the current page after changes have been made.

The example below does not include adding or removing items.


  <filter_form id="filter-form" meta={@meta} fields={[name: [op: :ilike_and]]} />

    row_item={fn {_, {pet, _}} -> pet end}
    <:col :let={p} label="Name" field={:name}>
      <%= %>

  <Flop.Phoenix.pagination meta={@meta} path={~p"/pets"} />

LiveView module

  @impl true
  def mount(_params, _session, socket) do
    # initialize empty stream on mount;
    # dom ID is based on row number, not on the pet ID
     stream(socket, :pets, [],
       dom_id: fn {_, index} -> "pets-#{index}" end

  @impl true
  def handle_params(params, _session, socket) do
    {pets, %{page_size: page_size} = meta} = Members.list_pets(params)
    pets = Enum.with_index(pets, &{&1, &2})
    len = length(pets)
    range = if len < page_size, do: len..(page_size - 1), else: []

    # insert all fetched pets into stream
    socket =
      Enum.reduce(pets, socket, fn pets, socket ->
        stream_insert(socket, :pets, pets)

    # remove pets from end of table in case the result count is
    # smaller than the page count (e.g. on the last page)
    socket =
      Enum.reduce(range, socket, fn index, socket ->
        stream_delete_by_dom_id(socket, :pets, "pets-#{index}")

    {:noreply, assign(socket, :meta, meta)}

I don’t know whether this is a good approach, and I also don’t know whether it’s worth it. After all, the table is already paginated, and thus only a limited amount of data is kept in the assigns anyway.

I would see more value in streams in situations where you want to render a show more button that loads more items without removing the already rendered items (or for infinity scroll implementations, if you work for the dark side). The implementation would be much simpler:

  • remove dom_id option from call to stream/4
  • no stream_delete_by_dom_id/3
  • no Enum.with_index/2
  • cursor-based pagination in list query
  • replace pagination component with a button that patches the view using the end cursor value from the meta struct


thanks for quick response. I’ll try it

In case someone still stumbles upon this thread, this solution is out of date. It is now possible to reset a stream in LiveView. No need for the awkward index handling above anymore.

  @impl true
  def mount(_params, _session, socket) do
    {:ok, stream(socket, :pets, [])}

  @impl true
  def handle_params(params, _session, socket) do
    {pets, meta} = Members.list_pets(params)
    {:noreply, socket |> assign(:meta, meta) |> stream(:pets, pets, reset: true}