The problem: Alpine components in a Laravel + Livewire page sometimes run setup code twice, leak event listeners after a Livewire re-render, or run x-init logic before data from a parent scope is actually ready — all lifecycle-timing issues that get worse the more Livewire updates a page.
Environment: Laravel 11, Alpine.js 3.x (Livewire-bundled), Livewire 3.x components with polling/updates, Vite 5.
The Error
// No single error message — the symptom is usually:
// 1. A duplicate event listener firing twice per click
// 2. A setInterval/setTimeout still running after the
// component's HTML has been removed from the DOM
Warning: Can't perform a React state update... // (if mixed with other libs)
// or simply: memory creeping up in DevTools > Performance
// as Livewire re-renders the same component repeatedly
Why It Happens
Alpine gives you three main points to hook into a component's life, and mixing them up is what causes leaks and double-firing:
x-init — runs once, right when the element it's on is initialized by Alpine, after reactive data (x-data) on that same element is set up. This is the right place for one-time setup tied to the component's own scope.
Alpine.data() component's init() — if you define components via Alpine.data('name', () => ({ init() {...} })), the init() method is called automatically once per instance, functionally similar to x-init but reusable across multiple elements using the same named component.
Cleanup via Alpine's effect/destroy system — Alpine doesn't expose a literal destroy() hook you write yourself, but it does clean up reactive effects automatically when an element is removed from the DOM. The catch: anything you registered manually and outside Alpine's reactivity — a raw setInterval, a window.addEventListener, a third-party library instance — is not tracked by Alpine and will keep running even after the element is gone, unless you clean it up yourself.
In a Livewire page, this matters far more than in a static site: every time Livewire re-renders a component (e.g. via polling, or a wire:click triggering a re-render), any Alpine element inside that Livewire component gets torn down and recreated. If x-init registered a raw setInterval without cleanup, you now have a second interval stacked on top of the first, and a third after the next re-render — that's the "memory creeping up" and "fires twice" symptom.
The Fix
Use Alpine's built-in $cleanup (available since Alpine 3.11) inside x-init to register a teardown function that runs automatically when the element is removed — this is the correct way to cancel intervals, timeouts, or listeners tied to a component:
<div
x-data="{ seconds: 0 }"
x-init="
let interval = setInterval(() => seconds++, 1000);
$cleanup(() => clearInterval(interval));
"
>
<span x-text="seconds"></span>
</div>
The same pattern applies to manually attached window/document listeners — always pair the listener with a matching $cleanup removal:
<div
x-data
x-init="
const handler = () => console.log('resized');
window.addEventListener('resize', handler);
$cleanup(() => window.removeEventListener('resize', handler));
"
></div>
If you're using Alpine.data() for a reusable component, put setup in init() and register cleanup through this.$cleanup() the same way (the magic properties are available on this inside Alpine.data() methods):
Alpine.data('pollingWidget', () => ({
seconds: 0,
init() {
const interval = setInterval(() => this.seconds++, 1000);
this.$cleanup(() => clearInterval(interval));
}
}));
To avoid the specific Livewire re-render churn, also add a stable wire:key to the wrapping element so Livewire only recreates it when it actually needs to, rather than on every render:
<div wire:key="polling-widget-{{ $record->id }}">
<div x-data="pollingWidget">...</div>
</div>
Step-by-Step
- Search your codebase for any
setInterval,setTimeout, or manualaddEventListenercalls insidex-initorAlpine.data()components. - For each one, add a matching
$cleanup(() => { ... })call that reverses it (clearInterval, clearTimeout, removeEventListener). - Add a stable
wire:keyto any wrapping element that lives inside a Livewire component prone to re-rendering (polling, form updates, search-as-you-type). - Rebuild with
npm run buildand open DevTools > Performance/Memory to confirm listener/interval counts stay flat across repeated Livewire updates. - If using
Alpine.data(), confirminit()only contains setup logic and that any teardown goes throughthis.$cleanup(), not a separate manual hook.
$cleanup requires Alpine 3.11 or later — if you're on an older Alpine bundled with an older Livewire 3.x release, check your installed version before relying on it. Older projects sometimes need to fall back to manually tracking and clearing intervals via a plain JS variable stored outside x-data, since $cleanup won't exist yet.Not sure if your Alpine version supports $cleanup? Ask on the Contact page and I'll help you check.