5 min read
On this page

Real-Time Features in LiveView

Once you're past the counter example, the real questions show up. How do I handle a form that updates as the user types? How do I append to a thousand-row list without re-rendering everything? How do I let users drop files in and see a progress bar? How do I make the server push something to a JS library — say, scroll a chat to the bottom or fire confetti?

This is where phx-* bindings, Phoenix.LiveView.JS, streams, and uploads earn their keep. None of this is exotic. All of it is plumbing that, in a React app, would be three libraries and a state machine.

Handling User Events

Every interactive element gets a phx-* binding. The common ones are phx-click, phx-submit, phx-change, phx-keyup, and phx-blur. The value you give the binding becomes the event name, and the matching handle_event/3 clause runs on the server.

def render(assigns) do
  ~H"""
  <form phx-change="validate" phx-submit="save">
    <input name="email" value={@email} phx-debounce="300" />
    <span :if={@error}><%= @error %></span>
    <button type="submit" disabled={@error != nil}>Save</button>
  </form>
  """
end

def handle_event("validate", %{"email" => email}, socket) do
  error = if String.contains?(email, "@"), do: nil, else: "Invalid email"
  {:noreply, assign(socket, email: email, error: error)}
end

def handle_event("save", %{"email" => email}, socket) do
  case Accounts.create_user(%{email: email}) do
    {:ok, _user} -> {:noreply, put_flash(socket, :info, "Saved")}
    {:error, _} -> {:noreply, put_flash(socket, :error, "Failed")}
  end
end

The phx-debounce="300" means "wait 300ms after the last keystroke before sending the event." Without it, you'd send a server round-trip on every character. There's also phx-throttle for events that fire continuously (mouse moves, scroll).

phx-value Params

When the click target needs context, attach data with phx-value-*. Each attribute becomes a key in the params map.

~H"""
<button phx-click="delete" phx-value-id={@post.id}>Delete</button>
"""

def handle_event("delete", %{"id" => id}, socket) do
  Posts.delete_post!(id)
  {:noreply, stream_delete(socket, :posts, %{id: id})}
end

This pattern shows up constantly in lists where each row has an action button.

push_event: Server-to-Client Messages

Sometimes you need to trigger something on the client that isn't a DOM update — playing a sound, showing a toast from a JS library, scrolling somewhere. push_event/3 sends a named event to the JS client, where a hook listens for it.

def handle_event("send_message", %{"text" => text}, socket) do
  Chat.send_message(socket.assigns.room_id, text)

  socket =
    socket
    |> push_event("scroll-to-bottom", %{id: "messages"})
    |> push_event("clear-input", %{id: "message-input"})

  {:noreply, socket}
end

On the client side, you write a tiny hook:

// app.js
let Hooks = {}
Hooks.ScrollBottom = {
  mounted() {
    this.handleEvent("scroll-to-bottom", ({id}) => {
      const el = document.getElementById(id)
      el.scrollTop = el.scrollHeight
    })
  }
}

Use push_event/3 sparingly. If you're reaching for it on every interaction, you're probably fighting LiveView instead of using it.

Phoenix.LiveView.JS — Client Commands Without JavaScript

Most "I need JS for this" cases are toggling classes, hiding modals, focusing inputs, dispatching browser events. Phoenix.LiveView.JS lets you do those declaratively from your template, with no round-trip to the server.

alias Phoenix.LiveView.JS

~H"""
<button phx-click={JS.toggle(to: "#menu")}>Menu</button>
<div id="menu" class="hidden">...</div>

<button phx-click={JS.show(to: "#dialog") |> JS.focus(to: "#dialog input")}>
  Open
</button>
"""

You can chain commands with the pipeline operator. You can dispatch DOM events (JS.dispatch("phx:my-event")), add and remove classes, transition between states with CSS classes, and even push a server event after running client commands.

~H"""
<div phx-click={JS.add_class("highlight") |> JS.push("select", value: %{id: @id})}>
  Click me
</div>
"""

This is where LiveView feels less like a server framework and more like a coherent system. You don't pick "client or server" — you express intent and the framework picks the cheap path.

Streams: The Right Way to Render Lists

The naive way to render a list in LiveView is assign(socket, :posts, posts) and iterate in the template. That works for 50 items. At 500, every change recomputes a diff over the whole list. At 5,000, your interactions feel sluggish.

stream/3 solves this. A stream is a server-managed collection where Phoenix tracks which DOM nodes correspond to which items. Inserts, updates, and deletes touch only the affected rows.

def mount(_params, _session, socket) do
  {:ok, stream(socket, :posts, Posts.list_posts())}
end

def handle_event("create", %{"post" => params}, socket) do
  {:ok, post} = Posts.create_post(params)
  {:noreply, stream_insert(socket, :posts, post)}
end

def handle_event("delete", %{"id" => id}, socket) do
  post = Posts.get_post!(id)
  Posts.delete_post(post)
  {:noreply, stream_delete(socket, :posts, post)}
end

def render(assigns) do
  ~H"""
  <div id="posts" phx-update="stream">
    <div :for={{dom_id, post} <- @streams.posts} id={dom_id}>
      <%= post.title %>
      <button phx-click="delete" phx-value-id={post.id}>Delete</button>
    </div>
  </div>
  """
end

The phx-update="stream" attribute tells LiveView that this container is stream-managed. Each child needs a unique id (Phoenix generates dom_id for you). The server only ever sends "insert this row," "update this row," or "remove this row" — never the whole list.

Streams also free you from holding the full collection in memory. Once an item is sent to the client, the server can forget about it. For an infinite-scroll feed of a million posts, this is the difference between working and OOM.

temporary_assigns: The Old Pattern

Before streams existed, the way to avoid sending a huge list every render was temporary_assigns. You declare an assign as temporary, and after each render Phoenix resets it to its default.

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(:messages, [])
   |> assign(:_temporary_messages, []),
   temporary_assigns: [messages: []]}
end

Streams are better for almost everything, but temporary_assigns still shows up in older code. If you see a LiveView with temporary_assigns in the mount return, that's why.

File Uploads

LiveView has first-class file uploads. You declare an upload in mount/3, render an input that knows about it, and consume the entries when the user submits.

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(:uploaded_files, [])
   |> allow_upload(:avatar,
     accept: ~w(.jpg .jpeg .png),
     max_entries: 1,
     max_file_size: 5_000_000
   )}
end

def handle_event("validate", _params, socket), do: {:noreply, socket}

def handle_event("save", _params, socket) do
  uploaded =
    consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry ->
      dest = Path.join("priv/static/uploads", Path.basename(path))
      File.cp!(path, dest)
      {:ok, "/uploads/#{Path.basename(dest)}"}
    end)

  {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded))}
end

def render(assigns) do
  ~H"""
  <form phx-change="validate" phx-submit="save">
    <.live_file_input upload={@uploads.avatar} />

    <div :for={entry <- @uploads.avatar.entries}>
      <progress value={entry.progress} max="100" />
      <button type="button" phx-click="cancel" phx-value-ref={entry.ref}>Cancel</button>
    </div>

    <button type="submit">Upload</button>
  </form>
  """
end

The progress bar updates as bytes stream in. There's no XHR boilerplate, no FormData, no manual chunking. Pinterest, in their LiveView experiments, leaned on this for image-heavy admin tools because it removed an entire class of frontend code.

For S3-direct uploads, swap consume_uploaded_entries with the external uploader pattern — the browser uploads straight to S3, and your server only gets a presigned URL flow.

Common Pitfalls

Putting phx-debounce on a submit button. Debounce belongs on phx-change/phx-keyup, not on submit — debouncing submit just makes the button feel broken.

Forgetting phx-update="stream" on stream containers. Without it, Phoenix tries to manage the children itself and you get duplicates or missing rows.

Using assign for large lists when you should use stream. The symptom is gradual slowdown as the list grows. The fix is mechanical: replace assign with stream, replace iteration with @streams.thing, add phx-update="stream".

Pushing a server event when a JS command would do. push_event requires a hook; JS.toggle doesn't. Reach for JS.* first.

Allowing uploads without max_file_size. The default is 8MB but explicit is better, and your storage bill is real.

Key Takeaways

LiveView gives you the four things real-time apps actually need: bidirectional events with phx-* and handle_event, server-driven client commands with push_event and Phoenix.LiveView.JS, efficient list updates with stream, and file uploads with progress. Use streams for anything bigger than a couple dozen items. Use JS.* before reaching for hooks. Use push_event only when the client genuinely needs imperative control over a JS library.