The Alpine Directive API in depth, with effect() and cleanup()
Alpine.directive() lets you build a completely new x-directive such as x-tooltip or x-clickaway, with full access to the DOM element, the expression written in the HTML, and a reactivity toolkit that plugs cleanly into Alpine's own lifecycle.
Table of Contents
- 1. Directive, magic property, or plugin: three different extension points
- 2. Basic syntax: Alpine.directive() and the right time to register it
- 3. The parameters in detail: el, the expression context, and the utilities
- 4. Practical example: a complete x-tooltip directive
- 5. Reactivity with effect(): why a single evaluate() often is not enough
- 6. cleanup(): tearing down event listeners and DOM nodes properly
- 7. Using modifiers and value in practice: configuration inside the attribute name
- 8. Priority against built in directives, and the naming convention
- 9. Common mistakes and how to find them in practice
- 10. Summary
- 11. FAQ
1. Directive, magic property, or plugin: three different extension points
Alpine offers not one but three different ways to add custom functionality, and the three get mixed up constantly in practice. A directive such as x-tooltip or x-clickaway is a new HTML attribute that Alpine recognizes while parsing an element and ties to its own callback function. It has direct access to the DOM element, can register event listeners, create its own child nodes, and react to changes in the expression. That is exactly what this article covers: the Alpine.directive() API in depth, not the related but distinct concepts of magic properties and plugins.
A magic property like $el or a custom $clipboard is not an attribute at all, it is a value or function invoked inside any Alpine expression through a dollar sign, for example in x-on:click="$clipboard(text)". It has no place of its own in the HTML markup and no lifecycle of its own, it is simply provided fresh every time the expression is evaluated. A plugin, created via Alpine.plugin(), is the distribution unit: a plugin can bundle any number of directives, magic properties, global stores, and Alpine.data() components into one npm package, the way @alpinejs/mask or @alpinejs/focus do. Anyone who only needs a single directive inside one project does not need to build a plugin for it, a direct call to Alpine.directive() is entirely sufficient.
2. Basic syntax: Alpine.directive() and the right time to register it
Registration follows a simple pattern: Alpine.directive(name, callback). The name is given in camelCase, even though it will later be used in dash-case in the HTML, so myTooltip becomes x-my-tooltip. On every element carrying the directive in the markup, the callback receives several arguments, covered in detail in the next section.
Timing matters: Alpine.directive() must be called before Alpine.start() runs, otherwise Alpine has already initialized the DOM and does not know about the new directive for elements that were already rendered. With a CDN setup that means registering inside the alpine:init event listener, with a bundler setup it means registering right before the manual Alpine.start() call.
document.addEventListener('alpine:init', () => {
// Registration MUST happen before Alpine.start()
Alpine.directive('tooltip', (el, { value, expression, modifiers }, { Alpine, effect, cleanup }) => {
// Directive logic goes here, covered in the next sections
})
})
3. The parameters in detail: el, the expression context, and the utilities
The Alpine.directive() callback receives three arguments. The first, el, is the raw DOM element the directive sits on, you can work with classList, addEventListener, or appendChild directly, exactly as with any other DOM element. The second argument is an object holding the parsed expression: value holds the part before a colon, for example right in x-tooltip:right, modifiers is an array of every dot modifier, for example ['delay', '500'] in x-tooltip.delay.500, and expression is the raw JavaScript expression as a string, the content between the quotes in the attribute.
The third argument is a utility object that gives access to Alpine's own machinery: evaluate(expression) evaluates the expression once inside the component's scope, evaluateLater(expression) returns a reusable evaluation function, effect(callback) registers a reactive function that reruns whenever a reactive value read inside it changes, and cleanup(callback) registers a function that runs once the element is removed from the DOM. Together these four functions form the complete toolkit for a well built custom directive.
4. Practical example: a complete x-tooltip directive
The API becomes clearest through a complete example. The following directive creates a tooltip element on mouseenter, positions it relative to the target element, and removes it again on mouseleave. The tooltip text comes from the expression and stays reactive through evaluateLater combined with effect, so when the underlying value in x-data changes, the already visible tooltip updates itself automatically without anyone having to trigger a manual update.
Alpine.directive('tooltip', (el, { expression }, { evaluateLater, effect, cleanup }) => {
let getText = evaluateLater(expression)
let tooltip = document.createElement('div')
tooltip.className = 'alpine-tooltip'
tooltip.style.cssText = 'position:absolute;display:none;padding:4px 8px;background:#111;color:#fff;border-radius:4px;font-size:12px;'
document.body.appendChild(tooltip)
let show = () => {
let rect = el.getBoundingClientRect()
tooltip.style.left = `${rect.left + window.scrollX}px`
tooltip.style.top = `${rect.top + window.scrollY - 32}px`
tooltip.style.display = 'block'
}
let hide = () => { tooltip.style.display = 'none' }
// effect() keeps the tooltip text reactive, evaluate() alone would not
effect(() => {
getText(text => { tooltip.textContent = text })
})
el.addEventListener('mouseenter', show)
el.addEventListener('mouseleave', hide)
// Without cleanup() the tooltip node and listeners would leak
cleanup(() => {
el.removeEventListener('mouseenter', show)
el.removeEventListener('mouseleave', hide)
tooltip.remove()
})
})
5. Reactivity with effect(): why a single evaluate() often is not enough
A common beginner mistake is reading the expression only once when the directive is created, using evaluate(), and storing the result in a fixed variable. That works for static values but ignores every later change. If the expression is instead wrapped inside effect(() => { ... }), Alpine automatically tracks which reactive properties were read inside the callback and reruns the callback whenever those properties change, exactly the way x-text or x-show work internally.
For the tooltip directive that means the text inside an already open tooltip updates live when, say, a counter or a server status the expression references changes, without the directive itself needing to reinitialize. This granularity, one effect() per directive instance instead of one global re-render, is one of the core advantages over a naive homegrown implementation built on a raw MutationObserver or manual polling.
6. cleanup(): tearing down event listeners and DOM nodes properly
Any directive that creates resources outside its own element, an extra tooltip node in body, a global listener on window, or a setInterval, has to release those resources again once the element disappears. That is exactly what cleanup(callback) is for: the given function runs automatically when Alpine removes the element, whether through x-if flipping its boolean, x-for shrinking its list, or plain classic DOM removal from JavaScript.
Forget cleanup() and, for the tooltip directive from the previous section, the created tooltip node keeps sitting in the DOM even after the original element is long gone, and the mouseenter listener stays active, holding a reference to a closure that should have been discarded. In a list of hundreds of elements that gets re-rendered frequently via x-for, that adds up to a textbook memory leak, reliably visible in Chrome DevTools under the detached DOM nodes filter of a heap snapshot.
7. Using modifiers and value in practice: configuration inside the attribute name
Modifiers let you place configuration directly inside the HTML attribute without complicating the expression itself. With x-tooltip.right.delay.500="'Text'", ['right', 'delay', '500'] lands in the modifiers array, and the directive can read from that, that the tooltip should appear to the right instead of above, and that the display should be delayed by 500 milliseconds. That matches exactly the pattern Alpine itself uses for built in directives like x-on:input.debounce.300ms.
The value, in turn, comes from an optional colon segment, for example x-tooltip:top="'Text'", and is suited to exactly one main option, while modifiers is meant for several, often boolean flags. Anyone combining both should place a small configuration step early in the callback that builds a clean options object from value and modifiers, rather than scattering that logic throughout the whole callback function.
8. Priority against built in directives, and the naming convention
Alpine evaluates directives on an element in a fixed order, among other things so that x-data always runs before x-bind and x-bind always runs before x-on. A custom directive slots into that order at a fixed default position, but .before('bind') or .after('bind'), chained right onto the registration, let you place it deliberately before or after a built in directive, for example when the custom directive depends on an attribute value that x-bind has already set.
Naming follows a fixed convention: multi word names are written in camelCase at registration time but are expected automatically in dash-case in the HTML. Alpine.directive('clickOutside', ...) becomes x-click-outside in the markup, not x-clickOutside. Anyone who misses that conversion and accidentally writes camelCase in the HTML gets no error at all, just a directive that never fires because Alpine does not recognize it in the DOM.
9. Common mistakes and how to find them in practice
By far the most common mistake is registering after Alpine.start(), usually because a module loads only after an asynchronous import. The symptom is unmistakable: the directive stays visible as a raw, unprocessed attribute in the rendered HTML, does not show up in the Alpine DevTools as an active directive, and no error appears in the console, because Alpine simply ignores an unknown x- attribute instead of flagging it.
The second most common mistake is missing cleanup() on directives that create their own DOM nodes or global listeners. Third, it is easy to overlook that evaluate() runs synchronously and only once, while genuine, continuously updated reactivity always needs effect(). Keeping these three points in mind, the correct registration timing, consistent cleanup, and deliberately reaching for effect() instead of evaluate() for dynamic values, avoids the vast majority of problems with custom directives.
| Aspect | x-Directive (Alpine.directive) | Magic Property ($x) | Plugin (Alpine.plugin) |
|---|---|---|---|
| Registration API | Alpine.directive(name, cb) |
Alpine.magic(name, cb) |
Alpine.plugin(cb) |
| Usage in markup | x-name="..." as an HTML attribute |
$name inside an expression |
arbitrary, bundles several extensions |
| Direct DOM access | Yes, via the el argument |
Only indirectly, via the el argument in the callback |
Depends on the plugin's contents |
| Typical use case | custom behavior on an element, e.g. a tooltip | reusable value or helper, e.g. clipboard access | npm package with several directives and magics |
| Reactivity | explicitly built via effect() |
freshly provided on every evaluation | depends on the plugin's contents |
Mironsoft
Alpine.js interactivity for Hyvä frontends
A Hyvä frontend that needs more interactivity, but without React overhead?
We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.
Custom Components
Develop interactive Alpine.js components for specific shop requirements.
Performance Review
Review existing Alpine.js implementations for reactivity pitfalls and performance.
Team Training
Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.
10. Summary
Alpine Directive API: the essentials at a glance
Directive
Alpine.directive(name, callback) registers a new x-attribute with full access to the DOM element.
effect()
Makes a directive reactive, the callback reruns whenever a value it reads changes.
cleanup()
Tears down event listeners and extra DOM nodes once the element is removed from the DOM.
Timing
Registration must happen before Alpine.start(), otherwise the directive stays unprocessed in the HTML.