Skip to content

El Conflicto de Soberanía en el DOM

Integración de Google Maps en Phoenix LiveView: aislamiento de reactividad y patrones para evitar la destrucción del DOM por SDKs de terceros

Por Gonzalo Oviedo · Junio 2026 · Lectura de 10 min

Phoenix LiveView ha revolucionado el desarrollo web al permitir interfaces de usuario ricas y en tiempo real sin salir del ecosistema de Elixir. Sin embargo, cuando introducimos SDKs de JavaScript de terceros que toman control directo del DOM (como Google Maps, Google Places Autocomplete, Stripe o Chart.js), nos encontramos con un conflicto fundamental: ¿quién tiene la soberanía sobre el DOM? ¿El motor de diffs de LiveView o el script de JavaScript del cliente?

Por diseño, Phoenix LiveView mantiene un árbol virtual del DOM en el servidor, calcula los cambios mínimos (diffs) ante cualquier actualización de estado y los envía al cliente mediante WebSockets. El cliente (a través de la biblioteca morphdom) aplica estos parches directamente sobre el DOM real de forma quirúrgica.

Por otro lado, SDKs como Google Maps o Google Places Autocomplete operan bajo la filosofía opuesta: una vez cargados, se adueñan de un elemento HTML, inyectan su propia estructura compleja de canvas, inputs y listeners, y esperan que nadie más toque ese fragmento del DOM.

Si LiveView parchea el DOM sobre un área controlada por un SDK externo sin precauciones, destruirá instantáneamente el estado interno de la biblioteca JavaScript, rompiendo la interactividad.

El Escenario: Configuración de Preferencias de Viaje

Section titled “ El Escenario: Configuración de Preferencias de Viaje”

Imaginemos una pantalla típica de una aplicación de carpooling (TeLlevo) donde el pasajero configura sus preferencias de viaje de Ida y Vuelta. La interfaz ofrece:

  • Un mapa interactivo de Google Maps que traza la ruta en tiempo real.
  • Dos inputs de dirección (Origen y Destino) integrados con Google Places Autocomplete.
  • Un selector de horario de salida (Horas y Minutos) de 24 horas.
  • Un selector de días de la semana de viaje.

Para separar la Ida de la Vuelta, la interfaz utiliza pestañas (Tabs). Cambiar de pestaña debe actualizar los valores del formulario de forma reactiva en el servidor para reflejar los datos almacenados de cada sentido.

El Antipatrón: El Formulario Reactivo Global

Section titled “ El Antipatrón: El Formulario Reactivo Global”

La solución intuitiva (y la que comúnmente proponen las IAs generativas de código) es envolver toda la interfaz dentro de una gran etiqueta de formulario de Phoenix LiveView:

<form phx-change="form_changed" phx-submit="save">
<div id="map-container" phx-hook="GoogleMap" phx-update="ignore"></div>
<input id="origin-input" value={@origin_address} />
<input id="destination-input" value={@destination_address} />
<select name="hora_hh">...</select>
<select name="hora_mm">...</select>
<button type="submit">Guardar</button>
</form>

A primera vista, esto parece correcto. Sin embargo, introduce un acoplamiento reactivo que rompe la aplicación de dos formas catastróficas:

La Solución: Aislamiento de la Reactividad

Section titled “ La Solución: Aislamiento de la Reactividad”

La solución elegante consiste en aplicar el principio de Aislamiento de Reactividad. En lugar de forzar a que toda la vista sea un único formulario gigante, dividimos la interfaz en zonas de soberanía independientes:

  1. Zona Libre de Formularios (Mapa y Direcciones): El contenedor del mapa y las entradas de dirección se colocan en contenedores <div> estándar, completamente fuera de cualquier formulario de Phoenix. Escribir en las direcciones no gatilla ningún evento de LiveView, permitiendo que el SDK de Google Places mantenga el control exclusivo y nativo de los inputs.
  2. Zona de Formulario Aislado (Horario): Envolvemos únicamente los selectores de hora y minuto dentro de un formulario local de LiveView. Cuando el usuario cambia la hora, solo este pequeño subárbol se actualiza, dejando el mapa y las direcciones completamente intactos.
  3. Envío Directo con phx-click: El botón de guardar se sitúa fuera del formulario y utiliza phx-click="save", gatillando el guardado de forma limpia y directa mediante el envío de los parámetros almacenados en el estado del socket.

A continuación se presenta la implementación de la LiveView en Elixir utilizando este patrón de aislamiento:

preferencia_viaje_live.exElixir
defmodule AutowebappWeb.Pasajero.PreferenciaViajeLive do
use AutowebappWeb, :live_view

# ... mount e inicialización del socket ...

@impl true
def handle_event("form_changed", %{"hora_hh" => hh, "hora_mm" => mm}, socket) do
  # Identifica el tab activo y actualiza únicamente las horas y minutos en el socket
  {hh_key, mm_key} =
    if socket.assigns.active_tab == :ida, do: {:ida_hh, :ida_mm}, else: {:vuelta_hh, :vuelta_mm}

  {:noreply,
   socket
   |> assign(hh_key, hh)
   |> assign(mm_key, mm)}
end

@impl true
def handle_event("form_changed", _params, socket) do
  {:noreply, socket}
end

@impl true
def render(assigns) do
  ~H"""
  <div class="flex flex-col h-full w-full">
    <!-- 1. Contenedor del Mapa (Fuera de cualquier formulario) -->
    <div class="relative h-[180px] w-full">
      <div id="map-container" phx-hook="GoogleMap" data-api-key={@google_maps_api_key} phx-update="ignore"></div>
    </div>

    <div class="p-6 space-y-6">
      <!-- 2. Inputs de Dirección (Fuera de formulario, controlados por Google Places) -->
      <div class="space-y-4">
        <Molecules.address_input_box
          origin_value={if @active_tab == :ida, do: @ida_origin_address, else: @vuelta_origin_address}
          destination_value={if @active_tab == :ida, do: @ida_destination_address, else: @vuelta_destination_address}
        />
      </div>

      <!-- 3. Formulario de Horario Aislado (Reactividad acotada) -->
      <.form :let={_f} for={%{}} phx-change="form_changed" class="space-y-2">
        <label class="text-sm font-bold flex items-center gap-1.5">Horario de Salida</label>
        <div class="flex items-center gap-2 bg-base-200/50 p-2 rounded-2xl border justify-center">
          <select name="hora_hh" class="select select-ghost font-bold text-lg">
            <%= for h <- 0..23 do %>
              <% h_str = String.pad_leading("#{h}", 2, "0")
              active_hh = if @active_tab == :ida, do: @ida_hh, else: @vuelta_hh %>
              <option value={h_str} selected={h_str == active_hh}>{h_str}</option>
            <% end %>
          </select>
          <span class="font-bold text-xl text-base-content/40">:</span>
          <select name="hora_mm" class="select select-ghost font-bold text-lg">
            <%= for m <- 0..59 do %>
              <% m_str = String.pad_leading("#{m}", 2, "0")
              active_mm = if @active_tab == :ida, do: @ida_mm, else: @vuelta_mm %>
              <option value={m_str} selected={m_str == active_mm}>{m_str}</option>
            <% end %>
          </select>
        </div>
      </.form>

      <!-- 4. Botón de Guardar Independiente -->
      <button type="button" phx-click="save" class="btn btn-primary w-full">Guardar</button>
    </div>
  </div>
  """
end
end

El Misterioso Fallo de la Navegación SPA (live_navigate)

Section titled “ El Misterioso Fallo de la Navegación SPA (live_navigate)”

Incluso aplicando el aislamiento de reactividad, muchos desarrolladores se topan con un bug fantasma: el mapa funciona perfectamente la primera vez que se entra a la pantalla, pero si presionas “Volver” y regresas, se rompe por completo.

Este comportamiento errático se debe a una regla de seguridad e interactividad crítica de los navegadores modernos al interactuar con el ciclo de vida de Phoenix LiveView:

La Solución Definitiva: Carga Dinámica en el Hook JS

Section titled “La Solución Definitiva: Carga Dinámica en el Hook JS”

Para resolver esto de forma definitiva y robusta ante cualquier flujo de navegación, se deben aplicar dos cambios de diseño:

A. Eliminar los scripts manuales del HEEx — Elimina por completo la etiqueta <script> manual de tus plantillas LiveView. Deja que el hook JS sea el único responsable de cargar el SDK de forma dinámica usando document.createElement("script") (el cual los navegadores sí ejecutan de forma garantizada).

B. Optimizar los botones de retorno con Navegación SPA — Reemplaza todos los botones “Volver” por <.link navigate={@to}>. Esto evita recargas completas del navegador al navegar hacia atrás, manteniendo window.google en memoria y haciendo que re-ingresar a cualquier pantalla con mapas sea instantáneo y libre de llamadas de red adicionales.

Reglas de Oro para Integraciones en LiveView

Section titled “ Reglas de Oro para Integraciones en LiveView”

Al integrar cualquier SDK de JavaScript de terceros que manipule el DOM, sigue estas tres directrices fundamentales para evitar conflictos de soberanía:

  1. Aísla los inputs dinámicos — Nunca coloques inputs vinculados a autocompletados o widgets externos dentro de formularios reactivos globales con phx-change. El re-renderizado destruirá sus event listeners.
  2. Usa phx-update=“ignore” con un ID único — Cualquier contenedor de mapas o gráficos (canvas) debe llevar phx-update="ignore" y un id persistente para que el motor de parches de LiveView lo preserve intacto en las actualizaciones.
  3. Encapsula la reactividad con formularios acotados — Si necesitas selectores reactivos (como horas, categorías o filtros), envuélvelos en su propio componente <.form> aislado. Esto limita el alcance del re-renderizado únicamente a ese pequeño fragmento del árbol del DOM.

La integración de SDKs interactivos de JavaScript en Phoenix LiveView no tiene por qué ser una fuente de dolores de cabeza o parches inestables en el cliente. Al comprender el ciclo de vida de los parches del DOM de LiveView y aplicar el patrón de Aislamiento de Reactividad, podemos construir interfaces web sumamente complejas, rápidas y robustas que combinan lo mejor de dos mundos: la simplicidad del desarrollo del lado del servidor con Elixir y la potencia interactiva de las mejores herramientas de JavaScript en el cliente.