Phoenix LiveView 1.1 released!

Posted on July 30th, 2025 by Steffen Deusch


LiveView 1.1.0 is available now!

LiveView reached the 1.0 milestone in December 2024. Since then, we’ve been hard at work building some long awaited features and improving the overall LiveView experience.

To update from LiveView 1.0 to 1.1, simply follow the CHANGELOG with the upgrade steps or alternatively run the Igniter upgrade task:

mix archive.install hex igniter_new
mix igniter.upgrade phoenix_live_view

Colocated Hooks

At ElixirConf EU 2023, Chris briefly mentioned colocated hooks to address the friction when you need to write a small JavaScript hook for your HEEx component, which would fit neatly next to your component code, but hooks required you to write that code to a whole separate file, maybe deal with JavaScript imports, etc.; While the feature did not make it into 1.0, in LiveView 1.1, you can now write:

<div id="todos" phx-update="stream" phx-hook=".Sortable">
  ...
</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".Sortable">
  import Sortable from "@/vendor/sortable"

  export default {
    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)
        }
      })
    }
  }
</script>

And LiveView will automatically extract the JavaScript at compile time. The only extra plumbing you need is a new import in your app.js:

  import {LiveSocket} from "phoenix_live_view"
+ import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
  import topbar from "../vendor/topbar"

  const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
  const liveSocket = new LiveSocket("/live", Socket, {
    longPollFallbackMs: 2500,
    params: {_csrf_token: csrfToken},
+   hooks: {...colocatedHooks},
  })

There are also two small changes needed in your esbuild configuration to tell it where to find the phoenix-colocated folder. We include that configuration by default for new Phoenix 1.8 apps, and we also have instructions in the changelog for existing apps.

If you carefully read the example code above, you might also have spotted the leading dot in the hook name .Sortable. This is a new feature in LiveView 1.1 to automatically prefix the hook name with the current module. To prevent name conflicts - LiveView hook names are global - when using colocated hooks where a hook is meant to belong to a specific component, LiveView enforces the hook name to be prefixed. This also allows library authors to not think about how to name their hooks. They can just call them .whatever and it is ensured that the name won’t conflict with any other user hooks. This feature is most useful for colocated hooks, but it can also be used with any other hook in your project.

Note that if you happened to name your hooks with a leading dot before LiveView 1.1, you’ll need to adjust those hook names.

Colocated JavaScript

When I first built the colocated hooks feature, José asked “why should it only be about hooks?”. Perhaps we could colocate anything?

Our abstraction for colocated hooks builds upon a feature we call “macro components”. For now, macro components are still a private API, since we want to think them through a little bit more, but the general idea is that you implement some callback that receives an AST representation of the HTML and can transform it. The transformation for colocated hooks is writing the text to a file and dropping it altogether from the rendered page. In a separate step, whenever code is compiled, we check the directory into which the code is extracted and generate a manifest file that contains all the necessary JavaScript imports.

A macro component could also be used to render markdown at compile time or perform compile time syntax highlighting. We hope to ship the API to allow this in LiveView 1.2.

Colocated hooks are built on a more generic Phoenix.LiveView.ColocatedJS module, which contains all the code for writing JavaScript to the phoenix-colocated folder and generating the manifest file. This means that you can use Phoenix.LiveView.ColocatedJS to extract generic JavaScript code, like global event handlers, and colocate those within your components:

def navbar(assigns) do
  ~H"""
  <nav>
    ...
    <.button
      phx-click={
        JS.toggle_class("expanded", to: {:closest, "nav"})
        |> JS.dispatch("nav:store-expanded", to: {:closest, "nav"})
      }
    >
      <.icon name="hero-bars-3" />
    </.button>
  </nav>

  <script :type={Phoenix.LiveView.ColocatedJS}>
    window.addEventListener("DOMContentLoaded", () => {
      const expanded = localStorage.getItem("nav:expanded") === "true";
      const nav = document.querySelector("nav");
      if (expanded) {
        window.liveSocket.js().addClass(nav, "expanded");
      }
    });

    window.addEventListener("nav:store-expanded", (e) => {
      const expanded = localStorage.getItem("nav:expanded") === "true";
      localStorage.setItem("nav:expanded", !expanded);
    })
  </script>
  """
end

This example demonstrates a navbar that stores its expanded state in the browser’s localStorage. You could also write a hook for this, but hooks only execute when the LiveView is mounted, which would often show a noticeable delay between the first load of the page until the expanded state is restored. With colocated JS, any non-hook JavaScript code that belongs to a component can have its code directly inside the HEEx template.

Colocated hooks and JS are most useful for small code snippets. While using a regular <script> tag means that syntax highlighting should work, most editors provide limited or no autocompletion.

Keyed Comprehensions

Rendering lists or other collections of items in LiveView has a potential pitfall: whenever an item in the list is changed, the whole list is re-rendered and sent over the wire.

<ul>
  <li :for={i <- @items}>{i.name}</li>
</ul>

If you change the @items assign, all items are sent again, even if you only added, removed or modified a single one. The Phoenix generators scaffold LiveViews that use streams, which is a mechanism to deal with large collections that was introduced during LiveView v0.18. With streams, the server does not keep the items in memory, which is very useful for large collections. The example above would look like this using streams:

<ul id="items" phx-update="stream">
  <li id={id} :for={{id, i} <- @streams.items}>{i.name}</li>
</ul>

Any change must then use stream management functions like stream_insert/4.

Still, streams are not a one size fits all solution for collections. In some cases you don’t want to deal with the management overhead of streams, you just want to declaratively write your for comprehension and still get an optimized diff over the wire.

LiveView does have another solution for this: LiveComponents.

LiveComponents were the first component abstraction introduced back in LiveView v0.4 to “compartmentalize state, markup, and events”. Because LiveComponents have their own state, they also perform their own change tracking.

<ul>
  <.live_component id={i.id} :for={i <- @items} module={MyListItem} item={i} />
</ul>

With a LiveComponent for each list item, changing a single item in the list only sends the diff for this item (and a list of component IDs!). While this code produces small diffs, it feels a bit clunky to create a whole separate module for the LiveComponent, if all you care about is the diff and you don’t need to handle any events inside the component.

Using LiveComponents to optimize the diff over the wire is not an unknown solution to this problem, but it’s something you need to look for and also easy to forget until you’re tasked with optimizing message sizes. Last year, a proposal was sent in the Elixir forum about introducing a :key attribute which would allow LiveView to optimize diffing in regular :for comprehensions:

<ul>
  <li :for={i <- @items} :key={i.id}>{i.name}</li>
</ul>

Initially, we weren’t sure how to tackle it, but after starting at Dashbit and having a couple discussions with the team, it turned out that LiveComponents could actually be the solution. In the first release candidate of LiveView v1.1, we officially introduced the :key attribute! Under the hood, each list item was still rendered by a LiveComponent, but we introduced some special handling to the variables introduced by :for to allow them to be correctly change tracked.

There were a couple of problems with this though. Because there was still a LiveComponent under the hood, :key only worked on regular HTML tags, not components. LiveComponents are patched separately by morphdom and require a single root element for the patch to be applied to. Since components can render multiple root elements, we cannot guarantee they meet this requirement. Furthermore, there’s still a comprehension involved, sending the list of all component IDs, even when only one actually changed. For small templates, the extra component IDs and slightly different diff structure for LiveComponents could actually lead to more overhead. There was an even bigger problem though: when rendering nested comprehensions, LiveView usually extracts all the static parts of the template into a special “template” section of the diff, only sending it once. With LiveComponents, this sharing breaks. If you then combine keyed and non-keyed comprehensions, you’d get an excessively large diff as LiveView would try to calculate the templates for each LiveComponent separately, sending lots of duplicate strings over the wire. We were not happy with those results, so we decided to start from scratch and completely rewrite how LiveView handles comprehensions.

LiveView 1.1 performs change tracking in comprehensions by default. It does not matter if you write <%= for i <- @items do %>...<% end %> or :for, when no key is given LiveView automatically uses an element’s index as the key. This already improves diffs a lot for cases where few entries in a list change. Using the index can lead to suboptimal situations though: if you prepend an item to the list, most of the time all subsequent items will be considered changed. To help LiveView detect this, a :key can be provided to optimize the diff even further.

In the past, using slots in HEEx could lead to unexpectly large diffs, because LiveView uses a comprehension under the hood to iterate over each slot entry when rendering slots:

<.my_list>
  <:list_item label="Name">
    <.some_component>The name</.some_component>
  </:list_item>
  <:list_item label="Price">{@price}</:list_item>
  <:list_item label="Actions">Something to do</:list_item>
  Some more text.
</.my_list>

Even though there’s no :for in sight, a change to @price would also send the name and actions columns over the wire. This is solved now with the new comprehension handling.

There is one remaining case that we cannot optimize yet: :for on slots.

<.my_list>
  <:list_item :for={item <- @items} label={item.label}>
    {item.text}
  </:list_item>
</.my_list>

If you have a template like this, because of the way slot rendering works at the moment, this cannot use keyed comprehensions for the slots. Furthermore, while you can use :key on components when using :for, you cannot use :key on slots like <:list_item>. Change tracking for anything inside of the <:list_item> is also not optimized in this case and will behave the same as in previous versions of LiveView.

Types for public interfaces

LiveView tries to enable writing rich user interfaces without the need to write large amounts of JavaScript code. Still, sometimes you need more control over what happens on the client - that’s why we have hooks - and every LiveView application ships with an app.js JavaScript file configuring LiveView’s LiveSocket. Most editors have great autocompletion support when working in JavaScript and to make interacting with LiveView’s JavaScript interfaces more pleasing, LiveView 1.1 now ships with type declarations for all of its public JavaScript API.

Because of the way Phoenix configures esbuild by default, you may need to hint your editor to look for types in the deps folder as well by creating an assets/tsconfig.json with the following content:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": ["node_modules/*", "../deps/*"]
    },
    "allowJs": true,
    "noEmit": true
  },
  "include": ["js/**/*"]
}

Portals

LiveView tracks each event sent to the server with clocks to prevent outdated server updates from rendering on the client. This required updates to morphdom - the library LiveView uses to efficiently update the DOM - to allow LiveView to apply updates to a cloned DOM tree instead of the real one while it is locked.

Could we also use this to build a “teleportation” feature like <Teleport> in Vue.js or createPortal in React by telling morphdom that instead of applying a patch to the actual element, it should instead apply it to some other element in the DOM? And it turns out that, yes, we can make it work!

First, I wanted to introduce a new binding called phx-portal with the idea that you just annotate an element with that binding and tell LiveView where to teleport it to:

<div id="foo" phx-portal="bar">
  ...
</div>

This had one big problem though: if you try to teleport an element because it would otherwise be invalid, for example a <form> inside another form, browsers would fix the HTML before LiveView had a chance to do the teleportation. The solution to this is to use <template> elements. Moreover, using a template also prevents the non-teleported elements from being seen in the initial disconnected render. So the second iteration of portals looked like this:

<template id="foo" phx-portal="bar">
  <div id="foo">...</div>
</template>

Because of the way template elements work and to ensure that morphdom can correctly apply updates, the template element itself must contain a single HTML element with an ID attribute. Writing this by hand feel awkward though - why should users even know about template elements in the first place? At the time, I let the PR sit for a while, because I somehow didn’t see the rather obvious solution: function components.

We just need to define a function component that accepts an ID, a target and its block content. So the final portal code looks like this:

<.portal id="foo" target="#bar">
  ...
</.portal>

Now, you might ask: when would I need a portal in the first place?

Portals are useful whenever you need to render something that should not be constrained by its surrounding context. If you try to render a simple tooltip which happens to be inside an element with overflow: hidden, you can quickly run into situations where content gets unexpectedly cut off. There are modern solutions to this with the Popover API or things like the native <dialog> element, which we do recommend to try out before resorting to <.portal>, but if you cannot use those for whatever reason, LiveView now ships with a solution.

For a smooth teleportation experience, we also adjusted the internal LiveView event handling code to ensure any events from inside teleported content are properly handled by the correct LiveView. You can even teleport LiveComponents and nested LiveViews through a <.portal> and everything should work as expected.

Moving from Floki to LazyHTML

Earlier this year, Dashbit released lazy_html, a small library based on lexbor, which strives to efficiently create output that “should match that of modern browsers, meeting industry specifications”. This means that LiveViewTest now supports modern CSS selectors like :is() and :has().

This should be mostly backwards compatible with existing tests. The only case where this will affect your tests is if you used Floki specific CSS selectors (fl-contains, fl-icontains) and passed those the element/3 function. Phoenix versions prior to v1.8 generated such a selector when using mix phx.gen.auth. Changing those tests to use the text_filter option included since LiveView v0.12 should be enough to make your tests pass again:

 {:ok, _login_live, login_html} =
   lv
-  |> element(~s|main a:fl-contains("Sign up")|)
+  |> element("main a", "Sign up")
   |> render_click()
   |> follow_redirect(conn, ~p"/users/register")

This changes only affects how LiveView parses your pages during test. If you use any other library in your own tests, you are not required to change them when you update to LiveView v1.1 (although users did report better performance with LazyHTML).

Closing thoughts

So, that’s LiveView 1.1! Please also have a look at the changelog, which contains mostly the same content as this blog post, but also includes a quick update guide and some notes about smaller improvements, such as JS.ignore_attributes and improved HEEx slot and line annotations.

If you have any feedback, please don’t hesitate to post a thread in the Elixir forum or contact me on BlueSky. In case you come across any regressions or new bugs in LiveView 1.1, please open up an issue in the repo.

A massive thank you to Dashbit for sponsoring this work and José Valim for his help implementing and reviewing countless pull requests!

Happy coding!

-Steffen

© 2025 phoenixframework.org | @elixirphoenix
Phoenix development is sponsored by Fly.io and Dashbit