Use `send_update_after/4` for delayed LiveComponent updates

almirsarajcic

almirsarajcic

2 days ago

Manual timer management with Process.send_after/3 in LiveComponents creates cleanup complexity. LiveView’s send_update_after/4 handles delayed updates automatically without timer tracking.

# ✅ BUILT-IN DELAY - Automatic timer management
defmodule NotificationComponent do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <div :if={@show} class="notification">
      <%= @message %>
    </div>
    """
  end

  def update(%{message: message}, socket) do
    # Show notification, auto-hide after 5 seconds
    send_update_after(__MODULE__, [id: socket.assigns.id, show: false], 5_000)

    {:ok, assign(socket, message: message, show: true)}
  end

  def update(%{show: false}, socket) do
    {:ok, assign(socket, show: false)}
  end
end

# Usage in parent LiveView
def handle_event("save", params, socket) do
  send_update(NotificationComponent, id: "notif", message: "Saved!")
  {:noreply, socket}
end

Without send_update_after/4, you need manual timer references and cleanup:

# ❌ MANUAL TIMERS - Complex cleanup required
defmodule NotificationComponent do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <div :if={@show} class="notification">
      <%= @message %>
    </div>
    """
  end

  def update(%{message: message}, socket) do
    # Cancel previous timer if exists
    if socket.assigns[:timer_ref] do
      Process.cancel_timer(socket.assigns.timer_ref)
    end

    # Set new timer and track reference
    timer_ref = Process.send_after(self(), {:hide_notification, socket.assigns.id}, 5_000)

    {:ok, assign(socket, message: message, show: true, timer_ref: timer_ref)}
  end

  # Parent LiveView needs to handle the message
  def handle_info({:hide_notification, component_id}, socket) do
    send_update(NotificationComponent, id: component_id, show: false)
    {:noreply, socket}
  end
end

How send_update_after works

send_update_after/4 schedules a component update and returns a cancellable reference:

# Schedule update and get reference
ref = send_update_after(StatusComponent, [id: "status", status: :idle], 10_000)

# Cancel if needed
Process.cancel_timer(ref)

The timer is automatically cleaned up when:

  • The LiveView process terminates
  • The component is removed from the DOM
  • A new update overrides the pending state

Common patterns

Auto-refresh components:

defmodule LiveStatsComponent do
  use Phoenix.LiveComponent

  def mount(socket) do
    # Refresh every 30 seconds
    schedule_refresh()
    {:ok, assign(socket, stats: load_stats())}
  end

  def update(assigns, socket) do
    if assigns[:refresh] do
      schedule_refresh()
      {:ok, assign(socket, stats: load_stats())}
    else
      {:ok, assign(socket, assigns)}
    end
  end

  defp schedule_refresh do
    send_update_after(__MODULE__, [id: "stats", refresh: true], 30_000)
  end
end

Debounced state changes:

defmodule SearchComponent do
  use Phoenix.LiveComponent

  def handle_event("search", %{"query" => query}, socket) do
    # Update immediately for UI feedback
    socket = assign(socket, query: query, searching: true)

    # Debounce actual search by 500ms
    send_update_after(__MODULE__, [id: socket.assigns.id, execute_search: query], 500)

    {:noreply, socket}
  end

  def update(%{execute_search: query}, socket) do
    results = perform_search(query)
    {:ok, assign(socket, results: results, searching: false)}
  end
end

Parent LiveView triggering delayed updates:

defmodule DashboardLive do
  use Phoenix.LiveView

  def handle_event("start-background-job", params, socket) do
    # Start job immediately
    {:ok, job} = start_job(params)
    send_update(StatusComponent, id: "status", status: :running)

    # Auto-reset status after job completes (estimated 10 seconds)
    send_update_after(StatusComponent, [id: "status", status: :idle], 10_000)

    {:noreply, assign(socket, current_job: job)}
  end

  def handle_event("show-temp-message", %{"text" => text}, socket) do
    # Show message in component, auto-hide after 3 seconds
    send_update(MessageComponent, id: "msg", text: text, visible: true)
    send_update_after(MessageComponent, [id: "msg", visible: false], 3_000)

    {:noreply, socket}
  end
end

Pro tip: Use send_update_after/4 instead of combining Process.send_after/3 with handle_info/2 for cleaner component-scoped delays.