Skip to main content
Client hooks provide lifecycle callbacks for custom client-side JavaScript when elements are added, updated, or removed by the server.

Overview

Hooks allow you to:
  • Initialize third-party JavaScript libraries
  • Manage client-side state
  • Handle complex DOM interactions
  • Integrate with external APIs
  • Build custom UI components
For simpler client-side operations, consider using JS Commands instead of hooks.

Basic Hook Setup

1. Define the Hook

/**
 * @type {import("phoenix_live_view").HooksOptions}
 */
let Hooks = {}

Hooks.PhoneNumber = {
  mounted() {
    this.el.addEventListener("input", e => {
      let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
      if(match) {
        this.el.value = `${match[1]}-${match[2]}-${match[3]}`
      }
    })
  }
}

let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks})

2. Use in Template

<input type="text" 
       name="user[phone_number]" 
       id="user-phone-number" 
       phx-hook="PhoneNumber" />
When using phx-hook, a unique DOM ID must always be set.

Lifecycle Callbacks

Called when element is added to DOM and LiveView has finished mounting:
mounted() {
  console.log("Element mounted:", this.el)
  // Initialize libraries, add event listeners
}
Outside a LiveView context, only mounted is invoked for elements present at DOM ready.

Hook Attributes

Hooks have access to these attributes:
AttributeDescription
this.elThe bound DOM node
this.liveSocketThe LiveSocket instance

Hook Methods

Push Events

Push events to the LiveView server:
// With callback
this.pushEvent("save", {data: "value"}, (reply, ref) => {
  console.log("Server replied:", reply)
})

// Returns promise if no callback
const reply = await this.pushEvent("save", {data: "value"})

Handle Events

Handle events pushed from server:
mounted() {
  this.handleEvent("highlight", ({duration}) => {
    this.el.classList.add("highlight")
    setTimeout(() => {
      this.el.classList.remove("highlight")
    }, duration)
  })
}

Upload Methods

Inject files into an uploader:
this.upload("avatar", files)

JS Commands

Access JS command interface:
mounted() {
  this.js()
    .show(this.el, {transition: "fade-in"})
    .addClass(this.el, "active")
}

Complete Hook Examples

Chart Integration

<div id="chart" phx-hook="Chart" data-points={Jason.encode!(@points)}></div>

Infinite Scroll

<div id="infinite-scroll" 
     phx-hook="InfiniteScroll" 
     data-page={@page}>
  <!-- Scrollable content -->
</div>

Map Integration

Hooks.Map = {
  mounted() {
    const {lat, lng, zoom} = this.el.dataset
    this.map = new MapLibrary.Map(this.el, {
      center: [parseFloat(lat), parseFloat(lng)],
      zoom: parseInt(zoom)
    })
    
    this.map.on("moveend", () => {
      const center = this.map.getCenter()
      this.pushEvent("map-moved", {
        lat: center.lat,
        lng: center.lng,
        zoom: this.map.getZoom()
      })
    })
    
    this.handleEvent("add-marker", ({lat, lng, title}) => {
      new MapLibrary.Marker({lat, lng})
        .setTitle(title)
        .addTo(this.map)
    })
  },
  
  destroyed() {
    this.map.remove()
  }
}

Colocated Hooks

Define hooks next to component code:
def phone_number_input(assigns) do
  ~H"""
  <input type="text" 
         name="user[phone_number]" 
         id="user-phone-number" 
         phx-hook=".PhoneNumber" />
  <script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
    export default {
      mounted() {
        this.el.addEventListener("input", e => {
          let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
          if(match) {
            this.el.value = `${match[1]}-${match[2]}-${match[3]}`
          }
        })
      }
    }
  </script>
  """
end

Importing Colocated Hooks

import {hooks as colocatedHooks} from "phoenix-colocated/my_app"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: {...colocatedHooks, ...Hooks}
})
Colocated hooks use dot syntax (.HookName) and are automatically namespaced by module name.

Class-Based Hooks

Define hooks as classes:
import { ViewHook } from "phoenix_live_view"

class DataTable extends ViewHook {
  constructor() {
    super()
    this.page = 1
  }
  
  mounted() {
    this.initTable()
  }
  
  initTable() {
    // Initialize DataTable library
  }
  
  updated() {
    this.refreshTable()
  }
  
  destroyed() {
    this.cleanup()
  }
}

let liveSocket = new LiveSocket(..., {
  hooks: { DataTable }
})

Advanced: DOM Integration

Preserve client-side DOM modifications across server updates:
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: Hooks,
  dom: {
    onBeforeElUpdated(from, to) {
      // Preserve data-js-* attributes
      for (const attr of from.attributes) {
        if (attr.name.startsWith("data-js-")) {
          to.setAttribute(attr.name, attr.value)
        }
      }
      
      // Preserve scroll position
      if (from.scrollTop > 0) {
        to.scrollTop = from.scrollTop
      }
    }
  }
})
onBeforeElUpdated is called just before patching—it cannot be deferred or cancelled.

Best Practices

Always clean up in destroyed():
mounted() {
  this.timer = setInterval(() => this.update(), 1000)
  this.listener = () => this.handleResize()
  window.addEventListener("resize", this.listener)
}

destroyed() {
  clearInterval(this.timer)
  window.removeEventListener("resize", this.listener)
}

Common Patterns

Debounced Push

Hooks.Search = {
  mounted() {
    this.timeout = null
    this.el.addEventListener("input", e => {
      clearTimeout(this.timeout)
      this.timeout = setTimeout(() => {
        this.pushEvent("search", {query: e.target.value})
      }, 300)
    })
  }
}

Two-way Data Binding

Hooks.Slider = {
  mounted() {
    // Client to server
    this.el.addEventListener("change", e => {
      this.pushEvent("value-changed", {value: e.target.value})
    })
    
    // Server to client
    this.handleEvent(`update-${this.el.id}`, ({value}) => {
      this.el.value = value
    })
  }
}

Loading States

Hooks.AsyncButton = {
  mounted() {
    this.el.addEventListener("click", async e => {
      this.el.disabled = true
      this.el.textContent = "Loading..."
      
      try {
        await this.pushEvent("submit", {})
      } finally {
        this.el.disabled = false
        this.el.textContent = "Submit"
      }
    })
  }
}

See Also

Build docs developers (and LLMs) love