You install TomSelect on a tag field. It looks perfect. Then the user ticks a checkbox somewhere else in the form, Livewire re-renders, and the pretty multi-select collapses back into a bare <select>. So you add wire:ignore, the widget stops collapsing — and now the server never sees what the user picked. Every fix creates a new symptom, because nobody explained the mechanism.
This is the mechanism. One mental model, then the full toolkit: wire:ignore, wire:ignore.self, wire:replace, $wire, @persist, #[Js], wire:ref, and the Livewire.hook() morph events — each paired with the exact failure it fixes. We'll build one real thing across the whole guide: a campaign editor with a TomSelect tag picker, a Flatpickr date range, and a Chart.js performance graph, all living inside a single Livewire 4 component that re-renders constantly.
Reproduce the morph bug before you fix it#
Start by building the broken version deliberately, because the shape of the breakage tells you which tool you need. Scaffold a component, drop a tom-select instance into it on page load, and give the component something unrelated to re-render — a toggle, a validation error, anything that triggers a round trip.
Pin your versions first. These are the current stable releases at the time of writing:
npm install tom-select@2.6.2 chart.js@4.5.1 flatpickr@4.6.13
A note on that last one: flatpickr has had no npm release since 4.6.13 in April 2022. It still works, but treat it as a stagnant dependency rather than a maintained one when you're choosing libraries for new work.
Now the component. Livewire 4 single-file components live in resources/views/components/ and colocate the class with the template:
<?php // resources/views/components/⚡campaign-editor.blade.php
use Livewire\Component;
new class extends Component {
public array $tags = [];
public bool $showAdvanced = false;
public function toggleAdvanced(): void
{
$this->showAdvanced = ! $this->showAdvanced;
}
};
?>
<div>
<select multiple data-tag-picker>
<option value="acquisition">Acquisition</option>
<option value="retention">Retention</option>
<option value="winback">Winback</option>
</select>
<button type="button" wire:click="toggleAdvanced">Advanced options</button>
</div>
<script>
new TomSelect($wire.$el.querySelector('[data-tag-picker]'), {
plugins: ['remove_button'],
});
</script>
Load the page, pick two tags, then click "Advanced options". The picker reverts to a plain select. That is the bug, and it is not a TomSelect bug — TomSelect built a pile of DOM that the server has never heard of, and Livewire has just told the browser to make the page look like the server's version again. Before you reach for a directive, get eyes on the round trip; if you're not sure which action is triggering the re-render, debugging Livewire round trips with Spatie Ray will show you the request that wiped your widget.
Understand what Livewire's morph does to your DOM#
Morphing is the whole story, so it's worth 90 seconds of theory. When a Livewire component updates, it does not replace its HTML with the newly rendered HTML. It walks the existing DOM tree and the new tree simultaneously, compares them node by node, and makes surgical changes only where they differ. That's what preserves focus, scroll position and event listeners between updates — and it's normally exactly what you want.
The problem is that morph's source of truth is the server's rendered HTML. Your JavaScript library's DOM has no representation on the server. So when morph reaches the <select> that TomSelect replaced with a <div class="ts-wrapper"> plus a hidden input plus a dropdown, it sees a mismatch and corrects it. Three failure shapes come out of that:
- Reversion — the library's DOM is diffed away and the plain markup comes back (the TomSelect case above).
- Duplication — your init code runs again on a re-rendered element and you end up with two instances stacked on one node. This is Chart.js's
"Canvas is already in use"error. - Orphaning — the library's instance survives in memory but its DOM node was removed, so listeners and observers leak.
The Livewire morphing docs are candid that morph can also mis-identify changes when conditionals appear and disappear mid-template. Livewire mitigates this with a look-ahead step and by injecting HTML comment markers around Blade conditionals (<!--[if BLOCK]><![endif]-->). Those markers are regex-derived, so on unusual templates they occasionally misfire; you can turn them off with 'inject_morph_markers' => false in config/livewire.php, but do that only as a last resort. The cheaper fix is to wrap conditionals and loops in a wrapper element that is always present, which gives morph a stable anchor.
Two related things help here. If a subtree is being re-rendered far more often than it needs to be, isolating it as an island limits the blast radius — Livewire 4's @placeholder and island loading covers that pattern. And in loops, a stable wire:key on each row is what stops morph from reusing the wrong node; the same discipline that keeps wire:intersect infinite scroll from duplicating rows keeps your per-row widgets attached to the right record.
Wrap the library's root element in wire:ignore#
wire:ignore tells Livewire to ignore the contents of an element even if they change between requests. Put it on the element that owns the library's DOM, and morph will walk right past the whole subtree.
<div wire:ignore>
<select multiple data-tag-picker>
<option value="acquisition">Acquisition</option>
<option value="retention">Retention</option>
<option value="winback">Winback</option>
</select>
</div>
The single most important thing to understand about this directive is what it costs you. Inside wire:ignore, Blade output is frozen at first render and Livewire's own bindings stop working. That produces two of the most-reported symptoms in the Livewire issue tracker.
First, the value stops flowing to the server. Livewire never diffs inside the ignored block, so wire:model on an element in there is inert — whatever the user selects is invisible to the component. This is the classic "I added wire:ignore and now nothing saves" report, and it's why the next two steps exist.
Second, server-driven content in there goes stale. If the <option> list comes from a PHP property that changes, the ignored <select> will keep showing the options it had at first render forever (livewire#1878 is exactly this: "Using wire:ignore I can't change public value anymore, only read the mount value"). Validation error blocks are the same trap (livewire#2433) — keep @error markup outside the ignored wrapper, or you'll wonder why messages never appear.
So the rule is: wire:ignore wraps exactly the node the library owns, and nothing else. Toolbars that Livewire renders, error messages, labels, help text — all of that stays outside.
Choose between wire:ignore and wire:ignore.self#
wire:ignore protects the element and its children. wire:ignore.self protects only changes to the attributes of the element itself, and lets its children continue to morph normally. Getting this backwards is the most common mistake in this whole topic, so anchor it to a concrete case.
Use wire:ignore when the library owns everything below the node — TomSelect, Select2, TinyMCE, Quill, Leaflet, Chart.js. You want the entire subtree left alone.
Use wire:ignore.self when the library only decorates the element it's attached to, and Livewire still needs to render inside it. Flatpickr in inline mode is the canonical example: it adds classes and ARIA attributes to your container while your Blade template continues to render the label and error message inside:
<div wire:ignore.self class="date-range">
<input type="text" data-date-range readonly>
@error('startsAt')
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
Here the error paragraph still updates on every request — that's the point — while Flatpickr's attribute mutations on the wrapper survive. Swap in plain wire:ignore and the error message freezes at whatever it was when the page first rendered.
Initialise the library from a component script#
Where you run the init code matters as much as which directive you use. DOMContentLoaded is the wrong hook: it has already fired by the time a lazily loaded component mounts, and it never fires again on a wire:navigate visit. In Livewire 4, a plain <script> tag inside a single-file or multi-file component is the right answer — Livewire owns the execution timing and runs it after the page has loaded but before the component renders, for every instance, including lazily loaded ones.
Load the library's assets once per page with @assets, and instantiate per component instance in <script>:
<div>
<div wire:ignore>
<select multiple data-tag-picker></select>
</div>
</div>
@assets
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.6.2/dist/js/tom-select.complete.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.6.2/dist/css/tom-select.css">
@endassets
<script>
const picker = new TomSelect($wire.$el.querySelector('[data-tag-picker]'), {
plugins: ['remove_button'],
options: @js($availableTags),
items: @js($tags),
});
</script>
@assets is guaranteed to be evaluated before your component script, and only once per page no matter how many instances of the component are on it. Component <script> blocks, by contrast, run once per instance — which is what you want for new TomSelect(...).
Two caveats. If you're using class-based components, where the PHP class and Blade view are separate files, you must wrap the script in @script / @endscript so Livewire handles the timing; a bare <script> in a class-based view will not be managed. And if you're initialising something that lives in your layout rather than a component — a global map or chat widget — listen for livewire:navigated instead of DOMContentLoaded, because that event fires on the initial load and on every wire:navigate visit, including browser back/forward.
You'll also see x-init recommended for this in older posts. It works, but it re-runs whenever Alpine re-initialises the element during a morph, which is precisely how you get duplicate instances (livewire#2520: "Having AlpineJS components encapsulated that are using x-init make all nested components x-init called each time livewire re-render the HTML"). Under wire:ignore that can't happen, but the component <script> approach is safer by default.
Push library changes into the component with $wire.$set#
Now close the loop you opened with wire:ignore. The library owns the DOM, so it must also own the job of telling Livewire when its value changes. Hook the library's own change event and write to the component property through $wire.
The v4 API is $wire.$set(name, value, live = true), or direct property assignment, which reads better:
<script>
const picker = new TomSelect($wire.$el.querySelector('[data-tag-picker]'), {
plugins: ['remove_button'],
options: @js($availableTags),
items: @js($tags),
});
picker.on('change', () => {
// Defer the network request; pass false as the third argument.
$wire.$set('tags', picker.getValue(), false);
});
picker.on('blur', () => $wire.$refresh());
</script>
That third false argument is the one worth knowing. $wire.$set('tags', value) sends a request immediately, so a user clicking through five tags fires five round trips. Passing false updates the property client-side without a request, and you flush it on blur or on submit with $wire.$refresh(). On a busy form that's the difference between snappy and sluggish.
Some libraries make this harder than it needs to be. Flatpickr, notoriously, doesn't dispatch a native input event when a date is picked, so wire:model never fires even outside wire:ignore — a Laracasts thread describes it as "the date property is not updating in real-time. It only updates when I click outside the input field." Either call $wire.$set() from Flatpickr's onChange, or dispatch the event yourself:
flatpickr($wire.$el.querySelector('[data-date-range]'), {
mode: 'range',
onChange: (dates, dateStr, instance) => {
instance.input.dispatchEvent(new Event('input', { bubbles: true }));
},
});
Because the widget's value now arrives via $wire, validation still lives entirely on the server where it belongs — the property is a normal Livewire property, so #[Validate] rules apply to it exactly as they would to a text input. If your component is accumulating a lot of these, moving the state into a Livewire 4 form object with validation attributes keeps the bridging code readable.
Push server-side state back into the library with $wire.$watch#
Traffic goes both ways. When the server changes the property — a reset button, a value loaded from a saved draft, a cascading filter — the ignored subtree won't notice, because that's the whole point of ignoring it. Watch the property from JavaScript and call the library's own API to update it.
$wire.$watch('tags', (value) => {
// Guard against the echo of our own change event.
if (JSON.stringify(value) === JSON.stringify(picker.getValue())) {
return;
}
picker.setValue(value, /* silent */ true);
});
The guard clause is not optional. Without it, $set fires the watcher, the watcher calls setValue, setValue fires TomSelect's change, and you have an infinite loop. Compare the incoming value to the library's current value and bail if they match; pass the library's "silent" flag where it offers one.
For cascading selects, where the options change rather than the value, watch the option source and rebuild rather than reassign:
$wire.$watch('availableTags', (options) => {
picker.clearOptions();
picker.addOptions(options);
picker.refreshOptions(false);
});
Note what we are deliberately not doing: we're not entangling the property into Alpine state. $wire.$entangle() is marked deprecated in the Livewire 4 docs and the @entangle Blade directive is explicitly discouraged because it creates duplicate state and misbehaves when DOM elements are removed. Read $wire directly instead. If you're carrying entangle-based code forward from v3, sharing state between Livewire 4 and Alpine with $wire walks through what replaces it.
Destroy the library instance on teardown#
This is the step almost everyone skips, and it's why long-lived SPA-feel pages get slower the longer you use them. When Livewire removes a component — a row deleted from a list, a modal closed, a filter that drops a widget from the page — the DOM node goes but your library instance does not. Its observers, timers and document-level listeners stay registered against a node nobody can see.
Livewire exposes per-element morph hooks for exactly this. Register them once, inside livewire:init:
// resources/js/app.js
document.addEventListener('livewire:init', () => {
Livewire.hook('morph.removed', ({ el }) => {
el.querySelectorAll?.('[data-tag-picker]').forEach((node) => {
node.tomselect?.destroy();
});
});
});
If your library keeps a registry rather than a back-reference on the node, use it. Chart.js does:
Livewire.hook('morph.removed', ({ el }) => {
el.querySelectorAll?.('canvas').forEach((canvas) => {
Chart.getChart(canvas)?.destroy();
});
});
Chart.getChart(canvas)?.destroy() is also the fix for the "Canvas is already in use — chart with ID 0 must be destroyed before the canvas can be reused" error, which happens when init runs twice against the same node. Call it defensively immediately before you construct a chart, as well as on removal.
For anything you set up inside an Alpine component, Alpine's own destroy() is the right place, and the Livewire docs call this out specifically for global listeners registered in init() — with wire:navigate, init runs on every page visit, so listeners accumulate unless destroy() unregisters them:
Alpine.data('chartWidget', () => ({
listeners: [],
init() {
this.listeners.push(
Livewire.on('metrics-updated', (payload) => this.render(payload))
);
},
destroy() {
this.listeners.forEach((stop) => stop());
Chart.getChart(this.$el.querySelector('canvas'))?.destroy();
},
}));
Reset stubborn widgets with wire:replace#
Sometimes you want the opposite of wire:ignore. wire:replace tells Livewire to skip diffing an element's children and replace them wholesale with the server's new HTML — useful when element reuse is what's causing the problem, particularly with web components that hold internal state, or Alpine state you actively want reset on each render.
<div wire:replace>
<json-viewer>@json($payload)</json-viewer>
</div>
The .self modifier extends that to the element itself as well as its children, which is how you force an Alpine wrapper's state back to its initial values:
<div x-data="{ open: false }" wire:replace.self>
<!-- "open" resets to false on every render -->
</div>
In practice I reach for wire:replace for two things: web components with shadow DOM, and libraries that are genuinely cheap to reconstruct but expensive to keep in sync. For a small sparkline that redraws in a millisecond, replacing the container and re-initialising is far less code than a two-way bridge. For a TinyMCE instance holding a half-written post, it would be a catastrophe. Cost of reconstruction is the deciding factor.
While we're here: wire:ref is worth knowing as a tidier alternative to data- attributes and IDs for targeting nodes. Name an element wire:ref="tagPicker" and reach it from your component script as this.$refs.tagPicker — scoped to the current component, so two instances on the same page don't collide the way duplicated IDs do.
Survive wire:navigate with the @persist directive#
wire:ignore and @persist get confused constantly, and the distinction is simple once you say it out loud. wire:ignore protects a node from morph, within the current page. @persist carries a node across wire:navigate page swaps. They solve different problems and neither substitutes for the other.
When wire:navigate swaps the page, Livewire looks for @persist blocks with matching keys on both pages and moves the existing element into the new DOM rather than recreating it. State, listeners and Alpine data all survive. A wire:ignore'd node gets no such treatment — it's part of the outgoing body, so it goes (livewire#8353 is a developer hitting exactly this after pressing the browser back button).
Persisted elements belong in your layout, outside any Livewire component:
{{-- resources/views/layouts/app.blade.php --}}
<body>
<main>{{ $slot }}</main>
@persist('player')
<audio src="{{ $episode->file }}" controls></audio>
@endpersist
@persist('map')
<div id="campaign-map" class="h-64"></div>
@endpersist
</body>
Two things the @persist docs are explicit about. @persist only works when navigation is handled by wire:navigate — a standard full page load will not preserve anything. And inside a persisted block you must use wire:current rather than server-side conditionals for active-link highlighting, because the block's server-rendered output is never re-evaluated. If you want scroll position kept as well, add wire:navigate:scroll to the scrollable child. The trade-offs of the whole navigate model are covered in getting an SPA feel with wire:navigate.
Hook into morph events for the edge cases#
Directives cover most of the ground. For the rest, the Livewire.hook() JavaScript API lets you intervene in the morph itself. The full set of morph hooks in Livewire 4 is morph.updating, morph.updated, morph.adding, morph.added, morph.removing and morph.removed for individual elements, plus morph and morphed around a whole component. There are also component.init and element.init.
The signature that unlocks the most is morph.updating, which hands you a skip callback:
document.addEventListener('livewire:init', () => {
Livewire.hook('morph.updating', ({ el, toEl, skip, childrenOnly }) => {
// Programmatic wire:ignore: leave any mounted widget alone.
if (el.dataset?.widgetMounted === 'true') {
skip();
}
});
});
That's wire:ignore expressed in JavaScript, which is what you want when the decision is dynamic — protect a chart only while it's animating, or only while a dropdown is open. childrenOnly() is the programmatic equivalent of wire:ignore.self.
The component-level pair is where you refresh a library after Livewire has finished rewriting the page around it:
Livewire.hook('morphed', ({ component }) => {
component.el.querySelectorAll('canvas').forEach((canvas) => {
Chart.getChart(canvas)?.resize();
});
});
Charts are the classic case, because a chart that sits inside a container whose width changed during morph will render at the old size until something tells it to resize. If your dashboard refreshes on a timer, this hook is what keeps the canvas honest — pair it with the patterns in auto-refreshing a Livewire 4 dashboard with wire:poll, and if you're doing the same inside an admin panel, Filament v4 chart widget polling and deferred loading covers the framework-managed version of the same problem.
One more tool for the specific case of "run this JS from a template without a round trip": the #[Js] attribute. A method marked #[Js] returns JavaScript as a string, which executes client-side with no request:
use Livewire\Attributes\Js;
#[Js]
public function clearTags()
{
return <<<'JS'
$wire.tags = []
JS;
}
Call it as $wire.clearTags(). Its server-side counterpart is $this->js('...'), which queues JavaScript to run when a server response arrives — the right hook for "save the record, then tell the chart to redraw".
Verify the integration with a browser test#
The half of this that can be tested cheaply is the bridge. Because the library writes through $wire, the component's public properties are the contract, and a standard Livewire test covers the server side without a browser at all:
use Livewire\Livewire;
it('accepts tags set from the client', function () {
Livewire::test('campaign-editor')
->set('tags', ['acquisition', 'winback'])
->assertSet('tags', ['acquisition', 'winback'])
->call('save')
->assertHasNoErrors();
});
it('keeps tags through an unrelated re-render', function () {
Livewire::test('campaign-editor')
->set('tags', ['retention'])
->call('toggleAdvanced')
->assertSet('tags', ['retention'])
->assertSet('showAdvanced', true);
});
That second test is the regression guard for the bug we started with, at least on the server side. The full set of component assertions available to you is covered in testing Livewire 4 components with Pest.
What a server-side test cannot tell you is whether the widget's DOM survived. For that you need a real browser. If you're on Pest 4 with the browser plugin, the assertion is direct — visit the page, confirm TomSelect's wrapper exists, trigger the unrelated re-render, and confirm it still exists:
it('survives a morph', function () {
$page = visit('/campaigns/create');
$page->assertSee('Advanced options')
->assertPresent('.ts-wrapper')
->click('@toggle-advanced')
->assertPresent('.ts-wrapper')
->assertNoJavascriptErrors();
});
assertNoJavascriptErrors() earns its keep here: duplicate-init failures like "Canvas is already in use" show up as console errors long before they show up as visible breakage. And when you're debugging by hand, the fastest check for a leak is to open devtools, trigger twenty re-renders, and count instances — if document.querySelectorAll('.ts-wrapper').length climbs, your teardown hook isn't firing.
Wrap up and roll the pattern out across your app#
The whole guide reduces to four decisions, taken in order. Does the library own this subtree? Then wire:ignore; if it only decorates the root element, wire:ignore.self. Does state need to cross the boundary? Then bridge it explicitly — $wire.$set(..., false) out, $wire.$watch() with a guard clause back in. Does the node need to outlive a page swap? Then @persist in the layout, not wire:ignore in the component. And whatever else you do, destroy the instance on morph.removed or in Alpine's destroy().
Standardise it once and every subsequent library is a twenty-minute job rather than an afternoon of guessing. I keep a single Blade component per library — picker, editor, chart — each owning its own @assets, its wire:ignore wrapper and its bridge, so the pattern only has to be right in one place.
From here, two natural next steps: if your widget-heavy forms are getting long, building a multi-step wizard with per-step validation shows how to split them without re-initialising everything on each step, and if the re-renders themselves are the problem rather than the widgets, Livewire 4 islands and skeleton loaders will shrink the surface morph has to touch in the first place.
FAQ#
Why does my JavaScript library break when Livewire updates the page?
Because Livewire updates the DOM by morphing — it compares the live DOM against the server's newly rendered HTML and surgically corrects the differences. Your library's DOM mutations have no representation on the server, so morph treats them as differences to correct. Depending on where the mutation happened, that shows up as the widget reverting to plain markup, duplicating, or being orphaned in memory after its node is removed.
What is the difference between wire:ignore and wire:ignore.self?
wire:ignore tells Livewire to skip the element and everything inside it, so nothing in that subtree is ever morphed again. wire:ignore.self protects only the attributes of the element it sits on, while its children continue to morph normally. Use the first when the library owns the whole subtree, like TomSelect or Chart.js, and the second when the library only decorates a container that Livewire still needs to render inside — for example a Flatpickr wrapper that also holds a validation error message.
How do I initialise a JavaScript library in a Livewire component?
Put the init code in a <script> tag inside the component (or wrapped in @script / @endscript if you're using a class-based component), and load the library itself in an @assets block. Livewire manages the timing, running the script after the page loads but before the component renders, once per component instance — which means lazily loaded and conditionally rendered components initialise correctly too. Avoid DOMContentLoaded, which has already fired for lazy components and never fires again on a wire:navigate visit.
How do I sync a third-party input like TomSelect with a Livewire property?
Listen to the library's own change event and write the value through $wire, because wire:model cannot see inside an ignored subtree. Call $wire.$set('property', value, false) to update the property without triggering a network request, then flush it with $wire.$refresh() on blur or on submit. For the reverse direction, use $wire.$watch('property', callback) and call the library's setter, with a guard clause comparing the new value to the library's current value so you don't create a feedback loop.
Why does my chart duplicate or disappear after a Livewire update?
Duplication means your init code ran twice against the same canvas — Chart.js reports this as "Canvas is already in use". Call Chart.getChart(canvas)?.destroy() before constructing a new chart, and put the canvas inside wire:ignore so morph stops touching it. Disappearing usually means the opposite problem: the canvas was inside a subtree that morph rebuilt, wiping the rendered chart while the instance stayed in memory. Wrap it in wire:ignore and update the existing instance's data and call .update() rather than recreating it.
How do I stop a JavaScript library re-initialising on every Livewire request?
The most reliable answer is to put the library's root node inside wire:ignore, so morph never revisits it and your init code never has a reason to run again. If the init lives in Alpine's x-init, be aware that Alpine re-initialises elements during a morph, which is a documented source of duplicate instances — either move it under wire:ignore or into a component <script> block. For dynamic cases, a Livewire.hook('morph.updating', ({ el, skip }) => ...) handler that calls skip() when a widget is already mounted gives you the same protection conditionally.
What is the difference between wire:ignore and the @persist directive?
wire:ignore protects an element from being morphed during updates to the current page. @persist carries an element across wire:navigate page swaps by moving the existing node into the new page's DOM. They're not interchangeable: an ignored node is part of the outgoing body during a navigation, so it is discarded, which is why widgets wrapped only in wire:ignore break on browser back and forward. Use @persist in your layout for things like audio players, maps and chat widgets, and wire:ignore inside components for form widgets.
How do I clean up a JavaScript library when a Livewire component is removed?
Register a Livewire.hook('morph.removed', ({ el }) => ...) handler inside a livewire:init listener, find the library's nodes in the removed element, and call the library's destroy method — node.tomselect?.destroy(), Chart.getChart(canvas)?.destroy(), and so on. If you set the library up inside an Alpine component, use Alpine's destroy() method instead, and unregister any global Livewire.on() listeners you added in init() there too, since init() runs again on every wire:navigate visit and those listeners otherwise accumulate.
Do I still need @entangle to share state with Alpine?
No. $wire.$entangle() is marked deprecated in the Livewire 4 docs and the @entangle Blade directive is explicitly discouraged, because it duplicates state and misbehaves when DOM elements are removed. Read and write Livewire properties directly through $wire instead — $wire.tags, $wire.$set(), $wire.$watch() — which is both simpler and the documented path forward for v4.