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;
    }
}

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.

Was this page helpful?

Voting requires cookies or local storage. What we store