Alpine composition
Vue composition API for AlpineJS.
Usage
1. Define the component
import { defineComponent, registerComponent } from 'alpine-composition';
import { ref, computed, watch, setAlpine } from 'alpine-reactivity';
setAlpine(Alpine);
const Button = defineComponent({
name: 'Button',
props: {
name: {
type: String,
required: true,
},
startCount: {
type: Number,
default: 0,
},
},
setup(props, vm) {
const { name, startCount } = toRefs(props);
const counter = ref(startCount.value);
const { increaseCounter, disposeCounter } = useCounter(counter);
const countFormatted = computed(() => {
return `Clicked button ${name.name} ${counter.value} times!`
});
watch(counter, () => {
vm.$dispatch('clicked', counter.value);
});
const onClick = () => {
increaseCounter();
};
vm.$onBeforeUnmount(() => {
disposeCounter();
});
return {
counter,
countFormatted,
onClick,
};
},
});
2. Register the component
This is where the magic happens. registerComponent
is a wrapper for Alpine.data
,
and it's thanks to this function that the component accepts props, has setup
method,
and more.
import { registerComponent } from 'alpine-composition';
document.addEventListener('alpine:init', () => {
registerComponent(Alpine, Button);
});
3. Use the component in the HTML
<div x-data="{ inputValue: 10 }">
<div
x-data="Button"
x-props="{ startCount: inputValue, name: 'MyButton' }"
>
<span x-text="countFormatted.value"></span>
<button @click="onClick">Click me!</button>
<div x-data="Button" x-props="{ name: 'InnerButton' }">
<span x-text="countFormatted.value"></span>
<button @click="onClick">Click me too!</button>
</div>
</div>
</div>
Installation
Via CDN
<script defer src="https://cdn.jsdelivr.net/npm/alpine-composition@0.x.x/dist/cdn.min.js"></script>
const { defineComponent, registerComponent } = AlpineComposition;
const Button = defineComponent({
name: 'Button',
props: { ... },
setup() { ... },
});
Via NPM
npm install alpine-composition
import { defineComponent, registerComponent } from 'alpine-composition';
const Button = defineComponent({
name: 'Button',
props: { ... },
setup() { ... },
});
Setup context and Magics
Inside of the setup()
method, you can access the Alpine component instance
as the second argument. This instance has all Alpine magics.
alpine-composition
adds 3 more magics:
-
$name
- Name of the component. Readonly property.
-
$props
- Props passed to the component as reactive object.
-
$attrs
- HTML attributes (as object) of the element where the x-data
was defined.
-
$options
- Initial component definition.
-
$emitsOptions
- Emits definition.
-
$emit
- Vue-like emit()
method. Unlike $dispatch
, $emit
expects event handlers
to be set as props (e.g. onClickButton
or onClickButtonOnce
for event 'clickButton'
).
Thus, handlers for events emitted with $emit()
must be explicitly defined on the component
that emits that event. In other words, the even does NOT bubble up. And when no event handler
is passed as a prop, the event is NOT sent.
Similar to Vue, the $emit
method has the event names and inputs autoamtically inferred from
the component options when using TypeScript.
-
$onBeforeUnmount
- Equivalent of Vue's onBeforeUnmount
.
Use this instead of Alpine's destroy
hook.
import { defineComponent } from 'alpine-composition';
import { ref, computed, watchEffect, setAlpine } from 'alpine-reactivity';
const Button = defineComponent({
name: 'Button',
setup(props, vm) {
const nameEl = vm.$el.querySelector('input[name="name"]');
console.log(vm.$name);
vm.$onBeforeUnmount(() => {
doUnregisterSomething();
});
const inputVal = ref('');
watch(inputVal, (newVal, oldVal) => {
vm.$dispatch('input', newVal);
vm.$emit('input', newVal);
}, { immediate: true });
},
});
Component isolation
To make the Alpine components behave more like Vue, the components created by
registerComponent
are automatically isolated from outer components. This can
be disabled by setting isolated: false
on the component.
Normally when you have an Alpine components like so:
<div x-data="{ hello: 'world' }" id="outer">
<div x-data="{ foo: 'bar' }" id="inner">
</div>
</div>
Then the inner component has access to the scope (data) from the outer components:
<div x-data="{ hello: 'world' }" id="outer">
<div x-data="{ foo: 'bar' }" id="inner" x-text="hello + ' ' + foo">
</div>
</div>
However, to mimic Vue API, we need to explicitly pass down data as props.
Because of this, components don't have access to the outer scopes:
<div x-data="{ hello: 'world' }" id="outer">
<div x-data="{ foo: 'bar' }" x-props="{ hello2: hello }" id="inner" x-text="hello2 + ' ' + foo">
</div>
</div>
To allow access to outer scopes, set isolated: false
on the component definition:
const Button = defineComponent({
name: 'Button',
props: { ... },
setup() { ... },
isolated: false,
});
Initializing component state from HTML
Sometimes, you may want to initialize a component to a certain state,
without exposing the inner state as props.
Imagine we have a button component, and we want to set the button width
at page load. We want different buttons to have different width,
but we want the width to remain constant for the rest of its existence.
For this alpine-composition
allows to set the internal component state
from JSON, passed to the component as data-x-init
attribute:
import { defineComponent, registerComponent } from 'alpine-composition';
const Button = defineComponent({
name: 'Button',
setup(props, vm) {
const buttonWidth = vm.buttonWidth || '20px';
const buttonStyle = `width: ${buttonWidth}; height: 40px; background: yellow;`;
return {
buttonStyle,
};
},
});
Where does the value vm.buttonWidth
come from? This is taken from the
component's initial state. See below:
<button x-data="Button" data-x-init='{ "buttonWidth": "100%" }' :style="buttonStyle">
Clock Me!
</button>
You can change which data key is used for initial data by setting the initKey
option.
initKey
should be a valid HTML attribute - it should be lowercase, and contain only
letters and dashes.
initKey
will be prefixed with x-
to avoid conflicts.
So, in the example below, we can define the initial state via the data-x-my-init
attribute by setting the initKey
option to my-init
:
const Button = defineComponent({
name: 'Button',
initKey: 'my-init',
setup(props, vm) { ... },
});
<button x-data="Button" data-x-my-init='{ "buttonWidth": "100%" }' ...>
Clock Me!
</button>
Extending
alpine-composition
comes with a plugin system that allows you to modify the Alpine instance
for each component registered with the respective registerComponent
function.
The example below is taken from Alpinui. Here, we defined a plugin for new magic
attribute $aliasName
accessible inside the setup()
method. $aliasName
returns
the value of aliasName
component option.
import {
createAlpineComposition,
defineComponent,
type Data,
type PluginFn,
} from 'alpine-composition';
import type { Alpine as AlpineType } from 'alpinejs';
import type { Magics } from 'alpinejs';
export interface CreateAlpinuiOptions {
components?: Record<string, any>;
}
declare module 'alpine-composition' {
interface AlpineInstance <P extends Data> extends Magics<P> {
$aliasName?: string;
}
interface ComponentOptions <T extends Data, P extends Data> {
aliasName?: string;
}
}
const aliasNamePlugin: PluginFn<Data, Data> = (vm, { options }) => {
const { aliasName } = options;
Object.defineProperty(vm, '$aliasName', {
get() {
return aliasName;
},
});
};
export function createAlpinui(
options: CreateAlpinuiOptions = {},
) {
const { components = {} } = options;
const { registerComponent } = createAlpineComposition({
plugins: [
aliasNamePlugin,
],
});
const install = (Alpine: AlpineType) => {
for (const key in components) {
registerComponent(Alpine, components[key]);
}
};
return {
install,
registerComponent,
};
}
After we have created createAlpinui
, we can register components with it like so:
import { defineComponent } from 'alpine-composition';
const Button = defineComponent({
name: 'Button',
props: { ... },
setup() { ... },
aliasName: 'ButtonAlias',
});
const alpinui = createAlpinui({
components: { ... },
});
alpinui.registerComponent(Alpine, Button);
Reference
See the docs