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;
}
}
Inherit the Guard Instead of Writing It
Writing the guard by hand means remembering it on every callback you ever write. WireKit ships a wrapper that carries it for you, and that is the route to reach for first. 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',
},
},
});
The helper is imported by path from the Composer package, so it is reachable only if your project bundles its own JavaScript. That is the ordinary Laravel setup and it is why this is the recommended route — but it is also why the hand-written guard below stays documented rather than deprecated: a project that loads WireKit's prebuilt bundle and writes no build step of its own cannot import anything, and still needs the line.
Alternative Guard Shapes
When you write the guard by hand, 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.
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.