Surviving LiveView DOM Patches with JS Hooks

TL;DR

Third-party JS libraries that create DOM elements get destroyed by LiveView patches. Use hooks with mounted, updated, and destroyed callbacks to detect and reinitialize.

The Problem

You add a JS library that creates DOM elements - a custom scrollbar, a rich text editor, a date picker. It works on first load. Then LiveView patches the DOM and everything disappears.

LiveView doesn’t know about elements your JavaScript created. When it patches, those elements are gone.

The Pattern

LiveView hooks give you lifecycle callbacks:

const Hooks = {
MyLibrary: {
mounted() {
// Initialize the library
this.instance = SomeLibrary.init(this.el)
},
updated() {
// LiveView patched the DOM - check if we need to reinitialize
if (this.libraryElementsDestroyed()) {
this.instance.destroy()
this.instance = SomeLibrary.init(this.el)
} else {
this.instance.update()
}
},
destroyed() {
// Clean up
this.instance.destroy()
}
}
}

The key is updated(). It fires after every LiveView DOM patch. Check if your library’s elements still exist.

Real Example: OverlayScrollbars

OverlayScrollbars creates .os-scrollbar elements inside your container. Here’s the full hook:

import { OverlayScrollbars } from 'overlayscrollbars'
const Hooks = {
AutoHideScrollbar: {
initScrollbar() {
this.scrollbar = OverlayScrollbars(this.el, {
scrollbars: {
theme: 'os-theme-custom',
visibility: 'auto',
autoHide: 'never',
},
overflow: { x: 'hidden' },
})
this.hideScrollbar()
},
showScrollbar() {
this.el.querySelectorAll('.os-scrollbar').forEach(sb => {
sb.style.opacity = '1'
sb.style.visibility = 'visible'
})
},
hideScrollbar() {
this.el.querySelectorAll('.os-scrollbar').forEach(sb => {
sb.style.opacity = '0'
sb.style.visibility = 'hidden'
})
},
mounted() {
this.initScrollbar()
// Show on scroll, hide after idle
this.el.addEventListener('scroll', () => {
this.showScrollbar()
clearTimeout(this.hideTimeout)
this.hideTimeout = setTimeout(() => this.hideScrollbar(), 1200)
}, true)
},
updated() {
// Check if OverlayScrollbars elements still exist
const scrollbarElements = this.el.querySelectorAll('.os-scrollbar')
if (scrollbarElements.length === 0) {
// LiveView destroyed them - reinitialize
if (this.scrollbar) {
this.scrollbar.destroy()
}
this.initScrollbar()
} else {
// Elements exist - just update
this.scrollbar.update()
}
},
destroyed() {
if (this.scrollbar) {
this.scrollbar.destroy()
}
}
}
}

Detection Strategies

How you detect “library elements destroyed” depends on the library:

Query for created elements:

updated() {
const elements = this.el.querySelectorAll('.library-created-class')
if (elements.length === 0) {
this.reinitialize()
}
}

Check instance state:

updated() {
if (!this.instance.isAttached()) {
this.reinitialize()
}
}

Check data attribute:

mounted() {
this.el.dataset.initialized = 'true'
this.instance = Library.init(this.el)
}
updated() {
// LiveView might have replaced the element entirely
if (this.el.dataset.initialized !== 'true') {
this.el.dataset.initialized = 'true'
this.instance = Library.init(this.el)
}
}

Attaching the Hook

In your LiveView template:

<div id="scrollable-container" phx-hook="AutoHideScrollbar">
<%= for item <- @items do %>
<div class="item"><%= item.name %></div>
<% end %>
</div>

The id is required - LiveView uses it to track the element.

Register hooks with your LiveSocket:

const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks,
})

When to Use phx-update=“ignore”

If the hooked element’s children never change from LiveView, use phx-update="ignore":

<div id="editor" phx-hook="RichTextEditor" phx-update="ignore">
<div class="editor-content"></div>
</div>

LiveView won’t touch anything inside. Your JS library has full control.

But if LiveView needs to update children (like a list), you can’t use ignore - use the detection pattern instead.

Common Mistakes

Forgetting to destroy:

// Bad - memory leak
updated() {
this.instance = Library.init(this.el)
}
// Good
updated() {
if (this.instance) this.instance.destroy()
this.instance = Library.init(this.el)
}

Reinitializing on every update:

// Bad - expensive, causes flicker
updated() {
this.instance.destroy()
this.instance = Library.init(this.el)
}
// Good - only reinit when needed
updated() {
if (this.needsReinit()) {
this.instance.destroy()
this.instance = Library.init(this.el)
} else {
this.instance.update()
}
}

Missing the true on addEventListener:

// Bad - might not catch events on child elements
this.el.addEventListener('scroll', handler)
// Good - capture phase catches all scroll events
this.el.addEventListener('scroll', handler, true)

References