Inicio / Elixir / Phoenix Framework: Web en Tiempo Real / LiveView Avanzado

LiveView Avanzado

LiveComponents, streams, uploads, JS hooks y navegación.

Intermedio Web
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

LiveView Avanzado

Una vez dominados los fundamentos de LiveView, es momento de explorar las características avanzadas: componentes con estado propio, streams para listas eficientes, hooks de JavaScript para interoperabilidad con el DOM, y assigns asíncronos para operaciones que toman tiempo.

live_component

Los live_component son módulos que encapsulan estado y lógica propios dentro de un LiveView padre:

defmodule MiAppWeb.FormularioComentarioComponent do
  use MiAppWeb, :live_component

  def mount(socket) do
    {:ok, assign(socket, form: to_form(%{"contenido" => ""}))}
  end

  def render(assigns) do
    ~H"""
    <div class="comentario-form">
      <.form for={@form} phx-submit="guardar" phx-target={@myself}>
        <.input field={@form[:contenido]} type="textarea" label="Tu comentario" />
        <.button>Publicar</.button>
      </.form>
    </div>
    """
  end

  def handle_event("guardar", %{"contenido" => contenido}, socket) do
    # Notificar al padre
    send(self(), {:comentario_creado, contenido, socket.assigns.articulo_id})
    {:noreply, assign(socket, form: to_form(%{"contenido" => ""}))}
  end
end

# Uso en el LiveView padre
<.live_component module={MiAppWeb.FormularioComentarioComponent}
  id="form-comentario" articulo_id={@articulo.id} />

Stateful vs Stateless Components

Es importante distinguir entre componentes con estado (stateful) y sin estado (stateless):

# STATELESS: Function component - no tiene estado propio
# Se re-renderiza cuando cambian los assigns del padre
defmodule MiAppWeb.CoreComponents do
  use Phoenix.Component

  def tarjeta_producto(assigns) do
    ~H"""
    <div class="tarjeta">
      <h3><%= @producto.nombre %></h3>
      <p>$<%= @producto.precio %></p>
    </div>
    """
  end
end

# STATEFUL: Live component - tiene su propio estado y ciclo de vida
# Maneja sus propios eventos con phx-target={@myself}
defmodule MiAppWeb.CarritoComponent do
  use MiAppWeb, :live_component

  def update(assigns, socket) do
    {:ok,
     socket
     |> assign(assigns)
     |> assign(:total, calcular_total(assigns.items))}
  end

  def render(assigns) do
    ~H"""
    <div class="carrito">
      <h2>Carrito (<%= length(@items) %>)</h2>
      <p>Total: $<%= @total %></p>
      <button phx-click="vaciar" phx-target={@myself}>Vaciar</button>
    </div>
    """
  end

  def handle_event("vaciar", _params, socket) do
    send(self(), :carrito_vaciado)
    {:noreply, assign(socket, items: [], total: 0)}
  end
end

Streams

Los streams permiten manejar listas grandes de forma eficiente sin mantener todos los elementos en memoria:

defmodule MiAppWeb.MensajesLive do
  use MiAppWeb, :live_view

  def mount(_params, _session, socket) do
    mensajes = Chat.listar_mensajes()
    {:ok, stream(socket, :mensajes, mensajes)}
  end

  def render(assigns) do
    ~H"""
    <div id="mensajes" phx-update="stream">
      <div :for={{dom_id, mensaje} <- @streams.mensajes} id={dom_id}
        class="mensaje">
        <strong><%= mensaje.autor %></strong>
        <p><%= mensaje.contenido %></p>
      </div>
    </div>
    """
  end

  def handle_info({:nuevo_mensaje, mensaje}, socket) do
    # Insertar al inicio del stream
    {:noreply, stream_insert(socket, :mensajes, mensaje, at: 0)}
  end

  def handle_event("eliminar", %{"id" => id}, socket) do
    mensaje = Chat.obtener_mensaje!(id)
    Chat.eliminar_mensaje(mensaje)
    # Eliminar del stream sin recargar todo
    {:noreply, stream_delete(socket, :mensajes, mensaje)}
  end
end

JS Hooks

Los hooks de JavaScript permiten ejecutar código JS personalizado cuando elementos del DOM se montan o actualizan:

# En app.js
let Hooks = {}

Hooks.InfiniteScroll = {
  mounted() {
    this.observer = new IntersectionObserver(entries => {
      const entry = entries[0]
      if (entry.isIntersecting) {
        this.pushEvent("cargar-mas", {})
      }
    })
    this.observer.observe(this.el)
  },
  destroyed() {
    this.observer.disconnect()
  }
}

Hooks.CopiarPortapapeles = {
  mounted() {
    this.el.addEventListener("click", () => {
      const texto = this.el.dataset.texto
      navigator.clipboard.writeText(texto)
      this.pushEvent("copiado", {texto: texto})
    })
  }
}

// En la configuración del LiveSocket
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: Hooks,
  params: {_csrf_token: csrfToken}
})
# En la plantilla HEEx
<div id="scroll-trigger" phx-hook="InfiniteScroll"></div>

<button phx-hook="CopiarPortapapeles" id="btn-copiar"
  data-texto={@codigo}>
  Copiar código
</button>

JS.push

JS.push permite enviar eventos al servidor con transformaciones JavaScript del lado del cliente:

alias Phoenix.LiveView.JS

def render(assigns) do
  ~H"""
  <div>
    <button phx-click={JS.push("toggle", value: %{id: @item.id})
      |> JS.toggle(to: "#detalle-#{@item.id}")}>
      Ver detalles
    </button>

    <div id={"detalle-#{@item.id}"} style="display:none">
      <%= @item.descripcion %>
    </div>

    <button phx-click={JS.push("eliminar", value: %{id: @item.id})
      |> JS.hide(to: "#item-#{@item.id}", transition: "fade-out")}>
      Eliminar
    </button>

    <!-- Transiciones CSS con JS -->
    <button phx-click={JS.toggle(to: "#menu",
      in: {"ease-out duration-200", "opacity-0", "opacity-100"},
      out: {"ease-in duration-150", "opacity-100", "opacity-0"})}>
      Menú
    </button>
  </div>
  """
end

Uploads en Tiempo Real

LiveView permite mostrar progreso de subida de archivos y previsualizaciones en tiempo real:

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(:imagenes_subidas, [])
   |> allow_upload(:fotos,
     accept: ~w(.jpg .png .webp),
     max_entries: 5,
     max_file_size: 10_000_000,
     progress: &handle_progress/3,
     auto_upload: true
   )}
end

defp handle_progress(:fotos, entry, socket) do
  if entry.done? do
    {:noreply,
     socket
     |> put_flash(:info, "#{entry.client_name} subido correctamente")}
  else
    {:noreply, socket}
  end
end

def render(assigns) do
  ~H"""
  <form phx-change="validate" phx-submit="save">
    <.live_file_input upload={@uploads.fotos} />
    <%= for entry <- @uploads.fotos.entries do %>
      <div>
        <.live_img_preview entry={entry} width="150" />
        <progress value={entry.progress} max="100"><%= entry.progress %>%</progress>
      </div>
    <% end %>
  </form>
  """
end

Async Assigns

Los assigns asíncronos permiten cargar datos sin bloquear el renderizado inicial:

defmodule MiAppWeb.DashboardLive do
  use MiAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Dashboard")
     |> assign_async(:estadisticas, fn ->
       {:ok, %{estadisticas: Reportes.calcular_estadisticas()}}
     end)
     |> assign_async(:ultimos_pedidos, fn ->
       {:ok, %{ultimos_pedidos: Pedidos.listar_recientes()}}
     end)}
  end

  def render(assigns) do
    ~H"""
    <h1>Dashboard</h1>
    <.async_result :let={stats} assign={@estadisticas}>
      <:loading>Cargando estadísticas...</:loading>
      <:failed :let={_reason}>Error al cargar estadísticas</:failed>
      <div class="stats">
        <p>Ventas: <%= stats.ventas_totales %></p>
        <p>Usuarios: <%= stats.usuarios_activos %></p>
      </div>
    </.async_result>
    """
  end
end

Resumen

LiveView avanzado ofrece herramientas poderosas para aplicaciones complejas. Los live_component encapsulan estado y lógica, los streams manejan listas grandes eficientemente, los JS hooks permiten interoperabilidad con JavaScript nativo, JS.push combina acciones del cliente y servidor, los uploads en tiempo real muestran progreso y previsualizaciones, y los async assigns cargan datos en segundo plano sin bloquear la interfaz del usuario.

🔒

Ejercicio práctico disponible

LiveComponents y Streams

Desbloquear ejercicios
// LiveComponents y Streams
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes