Integrating Alpine Filter Logic: Category Buttons, Live Filtering Without a Page Reload
Integrating Alpine Filter Logic: Category Buttons, Live Filtering Without a Page Reload
~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Now the interactive part of the team page comes together: category buttons that filter the grid live, with no page reload at all. The pattern is deliberately very similar to the accordion example from chapter 16 - except here it's not the visibility of individual entries that's decided, but whether they belong to the active category.
Step 1: embedding team data as JSON in x-data
As shown with the mini cart example in chapter 15, we embed the team data supplied by the ViewModel as JSON directly into x-data - from there, the entire filter logic runs purely client-side, with no further server request.
<?php
declare(strict_types=1);
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Serialize\Serializer\Json $json */
/** @var \Mironsoft\TeamPage\ViewModel\TeamMembers $teamViewModel */
$teamViewModel = $block->getData('team_view_model');
$json = $block->getData('json_serializer');
?>
<div
class="mx-auto max-w-6xl px-4 py-12 md:px-8"
x-data="teamFilter(<?= /* @noEscape */ $json->serialize($teamViewModel->getTeamMembers()) ?>)"
>json_serializer is bound as a second argument in the Layout XML - just like the ViewModel itself, only this time pointing at a Magento core class instead of custom code:
<argument name="json_serializer" xsi:type="object">Magento\Framework\Serialize\Serializer\Json</argument>Step 2: the Alpine.data() component
As learned in chapter 16, a named Alpine.data() component is worth it for several related methods, instead of a plain inline object:
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('teamFilter', (members) => ({
members: members,
activeCategory: 'alle',
setCategory(category) {
this.activeCategory = category;
},
isActive(category) {
return this.activeCategory === category;
},
get visibleMembers() {
if (this.activeCategory === 'alle') {
return this.members;
}
return this.members.filter(
(member) => member.category === this.activeCategory
);
},
}));
});
</script>visibleMembers is a getter - it recomputes the currently visible list on every access, based on activeCategory. That's the core of live filtering: when activeCategory changes from a button click, Alpine automatically recomputes visibleMembers and updates the DOM - with no manual rebuilding of the list.
Step 3: the category buttons
<div class="mb-8 flex flex-wrap gap-2">
<?php foreach ($teamViewModel->getCategories() as $category): ?>
<button
type="button"
@click="setCategory('<?= $escaper->escapeJs($category['key']) ?>')"
:class="isActive('<?= $escaper->escapeJs($category['key']) ?>')
? 'bg-brand-accent text-white'
: 'bg-slate-100 text-brand-slate hover:bg-slate-200'"
class="rounded-full px-4 py-2 text-sm font-semibold transition"
>
<?= $escaper->escapeHtml($category['label']) ?>
</button>
<?php endforeach; ?>
</div>Two escaper methods are used deliberately here: escapeJs() for the category key, since it lands inside a JavaScript expression (@click="..."), and escapeHtml() for the visible button text - exactly the escaper rules from chapter 8, now applied concretely.
Step 4: switching the grid to visibleMembers
The final change replaces the PHP foreach loop from chapter 20 with a client-side Alpine iteration using x-for (officially x-for combined with a <template> tag, the standard pattern for lists in Alpine):
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<template x-for="member in visibleMembers" :key="member.name">
<article class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="mb-4 h-40 w-full rounded-lg bg-slate-100"></div>
<h3 class="text-lg font-bold text-brand-dark" x-text="member.name"></h3>
<p class="text-sm text-brand-slate" x-text="member.role"></p>
</article>
</template>
</div>Important difference from chapter 20: since the grid is now built entirely client-side from the embedded JSON data, $escaper isn't used here anymore - x-text sets text content and automatically escapes it in the process (Alpine internally sets textContent, not innerHTML), just like $escaper->escapeHtml() does on the PHP side.
Achtung: Important: x-text is safe because it sets text content. x-html, on the other hand, inserts raw HTML and would be the wrong choice here - just like unguarded PHP output without $escaper (chapter 8).
Testing the current state
After a bin/cache-clean, /team now shows the four category buttons - clicking "Development" instantly filters the grid down to Anna and David, clicking "All" shows all four cards again. All without a page reload.
Tipp: In the browser dev tools, you can directly watch the Network tab and confirm that clicking a category button triggers no new request to the server - the best proof that filtering genuinely happens client-side.