Lists, transitions, and the DOM

Render keyed lists

Use template, x-for, and stable keys so DOM identity follows the underlying record.

x-for renders one element per item in an array. It goes on a <template> tag, and Alpine stamps out the template’s single child for each item.

Here is the issue list:

<ul x-data="{
  issues: [
    { id: 41, title: 'Login button unresponsive on Safari', status: 'open' },
    { id: 42, title: 'Export CSV misses last row', status: 'open' },
    { id: 43, title: 'Dark mode flashes on load', status: 'closed' }
  ]
}">
  <template x-for="issue in issues" :key="issue.id">
    <li x-data="{ expanded: false }">
      <button @click="expanded = !expanded" x-text="issue.title"></button>
      <div x-show="expanded" x-text="issue.status"></div>
    </li>
  </template>
</ul>

Two rules about the template. It must have exactly one root element inside, and it must be a <template>, not a <div>. The template itself never renders. Only its copies do.

What :key is for

When the array changes, Alpine has to decide which existing <li> matches which item. :key is how it decides. Give it a value that identifies the record and never changes. Here that’s issue.id.

With a stable key, Alpine moves the existing <li> when the item moves. The expanded state inside it, the focus, any typed text, all travel with the record.

The index is not a key

The common mistake is using the position:

<template x-for="(issue, index) in issues" :key="index">

Now the key says “the second row”, not “issue 42”. Expand the second row, then filter out the first one. Issue 42 is now in position one, and the DOM element in position one is the collapsed one. Your expanded state just jumped to a different issue.

Index keys are fine only for lists that never reorder, insert, or remove. Which is almost no list.

Test identity, not rendering

Rendering a list is easy to check. Identity is what breaks. So test the moves:

  • expand issue 42, then sort the list by title. Issue 42 should still be expanded, wherever it lands.
  • focus the button on issue 42 and delete issue 41. Focus should stay on 42.
  • open the editor on a row, add a new issue at the top. The editor should stay on the same row.

With :key="issue.id" all three pass. Switch to the index key and watch each one fail.

Keys must be unique

If two items share a key, Alpine warns in the console and the rendering gets unpredictable. Ids from a database are unique by construction. If you build items in the browser, generate an id when you create the item, not when you render it.

Try it on your board: render the rows with x-for, edit one, reorder the array from the console with Alpine.$data($0).issues.reverse(), and confirm the edit stays attached to the right issue.

Lesson completed