Skip to main content
WireKit
Copy for LLM

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:

  1. Store the resource under an underscore-prefixed property on this.
  2. Release it in destroy().
  3. 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.