Authoring Custom Alpine Plugins
WireKit ships with a curated set of Alpine.js plugins (wirekitAnimate,
wirekitStatAnimate, the reading-family helpers, the chart adapters,
and more). When you need something WireKit doesn't ship — a custom
scroll observer, a one-off keyboard handler, a domain-specific
animation — you write your own Alpine plugin.
This page documents the defensive-cleanup pattern every plugin should
follow. The pattern prevents a common class of bug: unguarded
lifecycle callbacks throwing TypeError: Cannot read properties of null after the host element has been torn down by Livewire morph,
conditional render, or SPA navigation.
If you've ever seen this in your browser console:
Uncaught TypeError: Cannot read properties of null (reading 'disconnect')
…this page is for you.
The Pattern at a Glance
Every Alpine plugin that holds a long-lived resource (observer, event handler, timer, requestAnimationFrame loop) must satisfy three rules:
- Store the resource under an underscore-prefixed property on
this. - Release it in
destroy(). - Null-guard every callback that dereferences it.
A minimal example that satisfies all three:
// resources/js/alpine/my-reveal.js
export default () => ({
_observer: null, // Rule 1
init() {
this._observer = new IntersectionObserver((entries) => {
if (! entries[0].isIntersecting) return;
// Rule 3 — guard before dereferencing the observer we
// stashed on `this`. A browser-queued callback can fire
// AFTER destroy() set _observer to null.
if (! this._observer) return;
this._observer.disconnect();
this._observer = null;
this.$root.classList.add('is-revealed');
});
this._observer.observe(this.$root);
},
destroy() { // Rule 2
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
},
});
Register it once in your resources/js/app.js:
import myReveal from './alpine/my-reveal.js';
document.addEventListener('alpine:init', () => {
Alpine.data('myReveal', myReveal);
});
And use it from Blade:
<div x-data="myReveal" class="opacity-0 transition-opacity duration-300">
Content that fades in when scrolled into view.
</div>
Why The Null-Guard Matters
The race window is asymmetric. The browser queues your
IntersectionObserver (or MutationObserver, ResizeObserver) callback
to fire on a future microtask. Between that queue and the actual
execution, your component might be torn down — Livewire morphed the
host element away, an @if branch flipped, or the user navigated.
When tear-down runs, your destroy() correctly sets
this._observer = null. But the callback the browser queued
still has a stale closure over this. When the callback finally
executes, this._observer.disconnect() throws.
Without the guard you get a silent (or noisy) TypeError in the
console. The page still renders correctly because the error doesn't
crash anything important — but your error tracker fills with noise,
and any browser-test assertion like assertNoSmoke() /
assertNoJavascriptErrors() reds out.
Cleanup Targets
Every resource your plugin holds on this should be released in
destroy(). The most common cleanup targets:
| Resource | Init pattern | Cleanup |
|---|---|---|
IntersectionObserver |
this._observer = new IntersectionObserver(...) |
this._observer.disconnect(); this._observer = null; |
MutationObserver |
this._mutationObserver = new MutationObserver(...) |
this._mutationObserver.disconnect(); this._mutationObserver = null; |
ResizeObserver |
this._resizeObserver = new ResizeObserver(...) |
this._resizeObserver.disconnect(); this._resizeObserver = null; |
window / document event listener |
window.addEventListener(name, this._handler) |
window.removeEventListener(name, this._handler); this._handler = null; |
setTimeout reference |
this._timer = setTimeout(...) |
clearTimeout(this._timer); this._timer = null; |
setInterval reference |
this._interval = setInterval(...) |
clearInterval(this._interval); this._interval = null; |
requestAnimationFrame reference |
this._raf = requestAnimationFrame(...) |
cancelAnimationFrame(this._raf); this._raf = 0; |
For each of these, the null-guard rule applies whenever the callback
itself dereferences this._<resource>. Event handlers that only
react to user input (without touching the handler reference) don't
need the guard, but observer callbacks almost always do — they
typically disconnect themselves after the first useful fire.
Why destroy() and Not $cleanup
Alpine.js has a $cleanup() magic helper, but it's only available
inside Alpine.magic(...) definitions — NOT in components defined
via Alpine.data(...). For plugin authoring, always use the
destroy() lifecycle method on the returned object.
// DOES NOT WORK in Alpine.data() — $cleanup isn't a function here.
init() {
this.$cleanup(() => this._observer.disconnect());
}
// DOES WORK — use the destroy() method instead.
destroy() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
}
Alternative Guard Shapes
The null-guard line is the canonical pattern. Two alternative shapes are equivalent and equally accepted:
// Optional chaining (terse, recommended when one-line).
this._observer?.disconnect();
this._observer = null;
// Explicit early return (verbose, recommended when the callback has
// additional state mutations after the disconnect).
if (! this._observer) return;
this._observer.disconnect();
this._observer = null;
this.somethingElse = false;
Both shapes survive the post-destroy race.
Or inherit the guard instead of writing it
Every shape above is something you have to remember on every callback you write. WireKit ships a wrapper that carries the guard for you.
It arrives with the Composer package rather than from npm, so you import it by
path — there is no npm install step and nothing to add to package.json. From
resources/js/app.js in a standard Laravel layout that is:
import { safeObserver } from '../../vendor/pushery/wirekit/resources/js/utils/safe-observer';
export default () => ({
init() {
// The callback is reached only while the observer is live. After
// stop() — and therefore after destroy() — this body does not run
// at all, so there is no null to guard against.
this._observer = safeObserver(IntersectionObserver, (entries) => {
entries.forEach((entry) => entry.isIntersecting && this.reveal());
}, { threshold: 0.5 });
this._observer.observe(this.$el);
},
destroy() {
this._observer.stop();
},
});
It does not free you from destroy(), and nothing could. A plugin that never
tears down leaks its observer whatever wrapper it used. What the helper removes
is the other half — the guard in every callback, which is the half that is easy
to forget and impossible to notice until a morph proves it.
It forwards observe, unobserve and disconnect, exposes the underlying
observer as .raw for the rare API it does not forward, and stop() is
idempotent — destroy() running twice is normal, not an error.
Works with IntersectionObserver, MutationObserver and ResizeObserver: the
constructor is the first argument, so there is one helper rather than three.
If the relative path bothers you, give it an alias in vite.config.js and import
@wirekit/utils/safe-observer instead:
// vite.config.js — one alias, then every WireKit source file is reachable by name.
export default defineConfig({
resolve: {
alias: {
'@wirekit': '/vendor/pushery/wirekit/resources/js',
},
},
});
Reduced-Motion Bypass
If your plugin animates, honor the user's reduced-motion preference by snapping to the final state and skipping the observer entirely:
init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// Snap to final state without setting up the observer.
this.$root.classList.add('is-revealed');
return;
}
// Normal setup with observer + null-guarded callback.
this._observer = new IntersectionObserver(/* ... */);
this._observer.observe(this.$root);
}
When the reduced-motion code path returns early, no observer is
created and no cleanup is needed — destroy() becomes a no-op via
the if (this._observer) guard.
Verifying Your Plugin
Run php artisan wirekit:doctor after authoring a custom plugin.
WireKit's doctor includes an Alpine-plugin cleanup-hygiene check
that scans resources/js/ for the anti-patterns this page
addresses and warns when it finds an observer without a destroy()
hook or a disconnect() without a null-guard.
If your plugin uses a pattern the heuristic doesn't recognize but is
intentionally correct (for example, a callback that captures the
observer in a local variable instead of this._observer), opt out
with a comment marker at the top of the file:
// wirekit-doctor: cleanup-ok
The doctor will skip that file. Use sparingly — the heuristic is calibrated for the common case, and most "intentional" exemptions in practice turned out to be bugs on closer inspection.
A Complete Example with Tests
A scroll-progress indicator plugin demonstrating all three rules, plus the matching browser test that would catch a regression.
// resources/js/alpine/scroll-progress.js
export default () => ({
_scrollHandler: null,
_rafId: 0,
init() {
// Mark current progress on the host so developers can style
// against `[data-progress="50"]` etc.
const update = () => {
this._rafId = 0;
const doc = document.documentElement;
const scrolled = window.scrollY;
const total = doc.scrollHeight - window.innerHeight;
const pct = total > 0 ? Math.round((scrolled / total) * 100) : 0;
this.$root.dataset.progress = String(pct);
};
this._scrollHandler = () => {
// Coalesce rapid scroll events into one rAF tick.
if (this._rafId) return;
this._rafId = requestAnimationFrame(update);
};
window.addEventListener('scroll', this._scrollHandler, { passive: true });
update();
},
destroy() {
if (this._scrollHandler) {
window.removeEventListener('scroll', this._scrollHandler);
this._scrollHandler = null;
}
if (this._rafId) {
cancelAnimationFrame(this._rafId);
this._rafId = 0;
}
},
});
A browser test ensuring the cleanup discipline holds:
// tests/Browser/ScrollProgressCleanupTest.php
it('scroll-progress plugin survives Livewire morph without console errors', function () {
$page = $this->visit('/long-article');
$page->wait(2);
$page->script("
window.__capturedErrors = [];
console.error = (...args) => window.__capturedErrors.push(args.map(a => String(a)).join(' '));
window.addEventListener('error', (e) => window.__capturedErrors.push('uncaught: ' + e.message));
");
// Trigger a Livewire morph that swaps the scroll-progress host.
$page->click('[data-test=\"toggle-article-mode\"]');
$page->wait(1);
$errors = $page->script('window.__capturedErrors || []');
$unguardedErrors = array_filter($errors, fn ($err) =>
is_string($err) && str_contains($err, 'reading')
);
expect($unguardedErrors)->toBe([]);
});
The combination of the discipline in the plugin source + the regression test ensures the bug class never re-surfaces in your project.