Alpine.js List Rendering: Why Keyed Templates Matter for State Persistence
Alpine.js renders lists with x-for on <template> tags. Using a stable :key preserves DOM node identity—focus, transitions, input values—when arrays reorder. A worked example shows why index keys break and how to choose Alpine over a SPA for server-rendered pages with interactive pockets.
25 Sept 2026, 18:06 UTC

When building modern web interfaces, we often hit a friction point where we need dynamic lists—like a filtered product catalog or a task board—but we don’t want the overhead of a full Single Page Application (SPA) framework like Vue or React. Alpine.js offers a middle ground by allowing you to enhance server-rendered HTML with reactive behavior. However, a common pitfall occurs when developers treat x-for as a simple loop construct, leading to lost UI state and broken transitions when data updates.
The Template Requirement
In Alpine.js, the x-for directive cannot be applied directly to standard HTML elements like <div> or <li>. It must be placed on a <template> tag. This is because Alpine uses the template as a blueprint; it clones the content inside for every item in your collection and injects the result into the DOM.
The template must contain exactly one root element. If you place multiple top-level elements inside the <template>, Alpine will fail to render the list correctly.
<template x-for="item in items" :key="item.id">
<li>...</li>
</template>
The Power of the :key Attribute
The most critical engineering decision when using lists in Alpine is how you handle the :key. Without a key, Alpine tracks items by their index in the array. If you sort the list or remove an item from the beginning, Alpine may simply update the text content of existing DOM nodes rather than moving the nodes themselves.
This becomes a problem when your list items have internal state—such as an open dropdown, a focused input, or an active CSS transition. If Alpine destroys and recreates the node, that local state is wiped. By providing a unique, stable identifier (like a database ID), you tell Alpine exactly which element corresponds to which data point, allowing it to reorder nodes efficiently.
Practical Example: Filterable Task List
In this example, we manage a list of tasks. We use a stable id to ensure that when we filter the list, the tasks that remain keep their "selected" state without being reset.
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<div x-data="{
tasks: [
{id: 1, text: 'Fix server bug', completed: false},
{id: 2, text: 'Write documentation', completed: false},
{id: 3, text: 'Deploy to production', completed: false}
],
filter: '',
get filteredTasks() {
return this.tasks.filter(t => t.text.toLowerCase().includes(this.filter.toLowerCase()));
}
}">
<input type="text" x-model="filter" placeholder="Filter tasks...">
<ul>
<template x-for="task in filteredTasks" :key="task.id">
<li :class="task.completed ? 'line-through' : ''">
<input type="checkbox" x-model="task.completed">
<span x-text="task.text"></span>
</li>
</template>
</ul>
</div>
In the code above, the :key="task.id" ensures that if you type in the filter box, the checkbox for a specific task remains tied to that specific task. If we used the array index as the key, the checkbox might appear to "jump" between different tasks as the list length shrinks.
Trade-offs and Limitations
While Alpine is excellent for "sprockets of interactivity," it is not a replacement for virtualized list solutions when rendering hundreds of items. The rendering cost grows linearly with list size, and Alpine lacks built-in windowing. For large datasets, consider a dedicated virtualization library or a heavier framework. Additionally, Alpine’s reactivity relies on Proxy-based observation (v3), so mutating arrays in place (e.g., push, splice) works, but reassigning the array (items = items.filter(...)) is the most reliable pattern for triggering updates.
When to Choose Alpine Over a SPA Framework
The practical decision Alpine embodies: when a page is mostly server-rendered with pockets of interactivity (dropdowns, tabs, filtered lists), adding Alpine (~15KB gzipped, no build step) is often cheaper than adopting a full SPA framework. You keep your existing backend templating, SEO-friendly markup, and progressive enhancement story while gaining reactive list rendering with x-for and :key.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.