---
title: Authoring Custom Alpine Plugins
visibility: guest
---

# 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:

```text
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:

```javascript
// 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`:

```javascript
import myReveal from './alpine/my-reveal.js';

document.addEventListener('alpine:init', () => {
    Alpine.data('myReveal', myReveal);
});
```

And use it from Blade:

```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.

```javascript
// 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:

```javascript
// Optional chaining (terse, recommended when one-line).
this._observer?.disconnect();
this._observer = null;
```

```javascript
// 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:

```javascript
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:

```javascript
// 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:

```javascript
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:

```javascript
// 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.

```javascript
// 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:

```php
// 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.
