Building Your Own Alpine Component: State, Events, Interactivity Without a Separate JS File
Building Your Own Alpine Component: State, Events, Interactivity Without a Separate JS File
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
To close out block 4, we'll build our own small Alpine component from scratch - a collapsible "accordion" section, the kind you'd need for an FAQ area, for example. The pattern is deliberately chosen so it later transfers easily to the team page's category filtering (chapter 21).
Step 1: defining the state
First we think about what state is actually needed. For an accordion, a single piece of information is enough: which entry (if any) is currently open.
<div x-data="{ openIndex: null }">
<!-- entries follow -->
</div>Step 2: the entries and the toggle logic
<div x-data="{ openIndex: null }">
<?php foreach ($faqViewModel->getEntries() as $index => $entry): ?>
<div class="border-b border-slate-200">
<button
type="button"
@click="openIndex = openIndex === <?= (int) $index ?> ? null : <?= (int) $index ?>"
class="flex w-full items-center justify-between py-4 text-left font-semibold text-brand-dark"
>
<?= $escaper->escapeHtml($entry['question']) ?>
<span x-text="openIndex === <?= (int) $index ?> ? '−' : '+'"></span>
</button>
<p x-show="openIndex === <?= (int) $index ?>" x-cloak class="pb-4 text-brand-slate">
<?= $escaper->escapeHtml($entry['answer']) ?>
</p>
</div>
<?php endforeach; ?>
</div>The core logic sits in the @click expression: if the clicked entry is already open, it closes (null), otherwise it becomes the new open entry. That way, at most one entry is ever open at the same time.
Step 3: a named Alpine.data() component instead of an inline object
For very simple cases, the inline object in x-data is perfectly enough. Once the logic gets more complex (several methods, reused in multiple places on the page), a named component via Alpine.data() in an inline <script> block is worth it - with correct CSP registration:
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('faqAccordion', () => ({
openIndex: null,
toggle(index) {
this.openIndex = this.openIndex === index ? null : index;
},
isOpen(index) {
return this.openIndex === index;
},
}));
});
</script>
<?php $hyvaCsp->registerInlineScript(); ?>
<div x-data="faqAccordion">
<!-- @click="toggle(0)", x-show="isOpen(0)" etc. -->
</div>The key benefit: toggle() and isOpen() are readable method names instead of one long inline expression, and the logic is bundled in one place instead of scattered across several x-data objects in the template.
Tipp: Rule of thumb: a plain inline object in x-data="{ ... }" as long as it's just one or two state variables and trivial expressions. Once real methods with several lines of logic are needed, Alpine.data() is worth it.
Ready for the continuous project
With Layout XML, ViewModels, escaping, CSP, Tailwind, and Alpine, all the building blocks needed for an own storefront page are now in place. Starting in chapter 17, we'll put all of it together in a single, continuous project: a team page with a category filter.