Phoenix LiveView 0.19 released

Posted on May 29th, 2023 by Chris McCord


LiveView 0.19.0 is out! This release includes long awaited dynamic form features, new stream primitives, and closes the gap on what you wished was possible with LiveView. It’s our last planned major release ahead of LiveView 1.0.

Open source TodoTrek showcase application

As demo’d and as promised in my ElixirConfEU keynote, I’m open sourcing the TodoTrek application, a Trello-like app which showcases the new stream features like drag-and-drop, infinite scrolling, dynamic forms, and more.

Here’s TodoTrek in action:

Now onto the features!

Enhanced Stream interface with resets and limits

Streams in LiveView 0.18 introduced a powerful way to handle large collections on the client without storing the collection in server memory. Streams allow developers to append, prepend, or arbitrary insert and update items in collection on the UI. This opened the door to many SPA-like usecases, such as realtime timelines, and infinite feeds, but a couple primitives were lacking.

With LiveView 0.19, streams close the gap on those primitives. Streams can be limited on the UI with the :limit option. This allows the developer to instruct the UI to keep the first N, or N items in collection when inserting new items to a stream. This is essential for a number of cases, such as the prevention of overwhelming clients with too much data in the DOM. Combined with new viewport bindings, virtualized, infinite lists can be implemented effortless, which we’ll see in a moment.

In addition to stream limits, streams also now support a :reset optionw, whichs clears the stream container on the client when updating the stream. This is useful for any case you need to clear results or reset a list, such as search autocomplete, or traditional pagination.

With just a simple stream(socket, :posts, posts, limit: 10) or stream(socket, :posts, [], reset: true), you can now accomplish complex client collection handling without having to think about it. But that’s just the start of what’s possible.

Nested streams with drop-in drag and drop

LiveView 0.19 supports nested streams, which allows drag and drop to be implemented in just a few lines of code. Imagine a trello board where your have named lists holding todo items. You can drag an drop re-order the lists themselves, or todos within the lists. You can also drag and drop todos across lists. All this is possible in LiveView now with a shockingly small amount of code on the client and server. For client-side drag-and-drop itself, you can bring your own library and integrate with a dozen of lines of code. For example, here’s the TodoTrek drag and drop for handling todos and lists:

import Sortable from "../vendor/sortable"
Hooks.Sortable = {
  mounted(){
    let group = this.el.dataset.group
    let sorter = new Sortable(this.el, {
      group: group ? {name: group, pull: true, put: true} : undefined,
      animation: 150,
      dragClass: "drag-item",
      ghostClass: "drag-ghost",
      onEnd: e => {
        let params = {old: e.oldIndex, new: e.newIndex, to: e.to.dataset, ...e.item.dataset}
        this.pushEventTo(this.el, this.el.dataset["drop"] || "reposition", params)
      }
    })
  }
}

Here we import sortable.js, then wire it up as a phx-hook. Now any stream container can wire up streams with drag-and-drop with the following markup:

<div id="todos" phx-update="stream" phx-hook="Sortable">
  ...
</div>

When an item is dropped, the LiveView will receive a “reposition” event with the new and old indexes, plus whatever data attributes exists.

Viewport bindings for virtualized, infinite scrolling

LiveView 0.19 introduces two new phx bindings for handling viewport events – phx-viewport-top and phx-viewport-bottom. These events a triggered when the first child of a container reaches the top of the viewport, or the last child reaches the bottom of the viewport. Combining these two events with stream limits, allows you to perform “virtualzed” infinite scrolling, where only a small set of the items exist in the DOM, while the user experiences the list a large, or infinite set of items. Prior to LiveView 0.19, this kind of feature required users to escape to complex JavaScript hooks, but no more.

Dynamic forms with new inputs_for

Dynamicaly adding and removing inputs with inputs_for is now supported by rendering checkboxes for inserts and removals. Libraries such as Ecto, or custom param filtering, can inspect the paramters and handle the added or removed fields. This can be combined with Ecto.Changeset.cast/3‘s :sort_param and :drop_param options. For example, imagine a parent Ecto.Schema with an :emails has_many or embeds_many association. To cast the user input from a nested inputs_for, one simply needs to configure the options:

schema "lists" do
  field :title, :string

  embeds_many :emails, EmailNotification, on_replace: :delete do
    field :email, :string
    field :name, :string
  end
end

def changeset(list, attrs) do
  list
  |> cast(attrs, [:title])
  |> cast_embed(:emails,
    with: &email_changeset/2,
    sort_param: :emails_sort,
    drop_param: :emails_drop
  )
end

Here we see the :sort_param and :drop_param options in action.

Note: on_replace: :delete on the has_many and embeds_many is required when using these options.

When Ecto sees the specified sort or drop parameter from the form, it will sort the children based on the order they appear in the form, add new children it hasn’t seen, or drop children if the drop parameter intructs it to do so.

The markup for such a schema and association would look like this:

<.inputs_for :let={ef} field={@form[:emails]}>
  <input type="hidden" name="list[emails_sort][]" value={ef.index} />
  <.input type="text" field={ef[:email]} placeholder="email" />
  <.input type="text" field={ef[:name]} placeholder="name" />
  <label>
    <input type="checkbox" name="list[emails_drop][]" value={ef.index} class="hidden" />
    delete
  </label>
</.inputs_for>

<label class="block cursor-pointer">
  <input type="checkbox" name="list[emails_sort][]" class="hidden" />
  add more
</label>

We used inputs_for to render inputs for the :emails association, which containes an email address and name input for each child. Within the nested inputs, we render a hidden list[emails_sort][] input, which is set to the index of the given child. This tells Ecto’s cast operation how to sort existing children, or where to insert new children. Next, we render the email and name inputs as usual. Then we render a label containing the “delete” text and a hidden checkbox input with the name list[emails_drop][], containing the index of the child as its value. Like before, this tells Ecto to delete the child at this index when the checkbox is checked. Wrapping the checkbox and textual content in a label makes any clicked content within the label check and uncheck the checkbox.

Finally, outside the inputs_for, we render another label with a value-less list[emails_sort][] checkbox with accompanied “add more” text. Ecto will treat unknown sort params as new children and build a new child.

One great thing about this approach is allows both LiveView and traditional views to use the same form and casting primitives.

Closing the gap on what’s possible

The features in 0.19 allows effortless implementation of all kinds of usecases previously relegated to single page applications. Think chat messaging with infinite scroll history like Slack, or bidirectional social timelines like Twitter, or Todo apps like Trello with nested drag and drop re-ordering. It’s now possible without having to even think about the necesary details on the client. This release sets us up to focus on getting LiveView 1.0 out the door. Stay tuned!

Happy coding!

–Chris