Skip to content

Lists & Loops

The data-for attribute creates multiple instances of an element for each item in an array. This is Hyper's way of handling dynamic lists that change on the client side—lists where users can add, remove, or reorder items without a server round trip.

Basic Usage

data-for (Hyper)

Use data-for on a <template> element to loop over an array signal:

blade
<div data-signals="{users: [
    {id: 1, name: 'Alice'},
    {id: 2, name: 'Bob'}
]}">
    <template data-for="user in $users">
        <div data-text="user.name"></div>
    </template>
</div>

This creates a div for each user. When the users signal changes, the DOM updates automatically.

Syntax

item in $signalName
  • item - Your name for each array element
  • $signalName - The signal containing your array

Template Requirements

The template must contain exactly one root element:

blade
<!-- ✅ Correct -->
<template data-for="user in $users">
    <div>
        <h3 data-text="user.name"></h3>
        <p data-text="user.email"></p>
    </div>
</template>

<!-- ❌ Wrong - multiple roots -->
<template data-for="user in $users">
    <h3 data-text="user.name"></h3>
    <p data-text="user.email"></p>
</template>

Using Keys

data-for__key (Hyper)

Keys tell Hyper which items are which. This is critical for efficient updates and preserving element state:

blade
<template data-for__key.id="user in $users">
    <div data-text="user.name"></div>
</template>

The __key modifier specifies which property uniquely identifies each item. Without keys, Hyper can't tell if you reordered items or replaced them, leading to incorrect behavior.

Default Keys

If you don't specify a key, Hyper automatically looks for:

  1. id property
  2. uuid property
  3. key property

If none exist, it falls back to array index (which breaks when items are reordered).

Why Keys Matter

blade
<!-- Without proper keys, form state gets confused -->
<div data-signals="{todos: [
    {id: 1, title: 'Buy milk', done: false},
    {id: 2, title: 'Walk dog', done: true}
]}">
    <template data-for__key.id="todo in $todos">
        <div>
            <input type="checkbox" data-bind="todo.done" />
            <span data-text="todo.title"></span>
        </div>
    </template>
</div>

When you reorder todos, the keys ensure checkboxes stay with their correct items.

Working with Index

Getting the Index

Access the current index with a second variable:

blade
<template data-for="item, index in $items">
    <div data-text="(index + 1) + '. ' + item"></div>
</template>

The index starts at 0 and updates automatically when items are added, removed, or reordered.

Dynamic Buttons Based on Index

blade
<div data-signals="{
    phones: [{id: 1, number: '555-1234'}],
    nextId: 2
}">
    <template data-for__key.id="phone, index in $phones">
        <div>
            <label data-text="'Phone ' + (index + 1)"></label>
            <input data-bind="phone.number" />

            <!-- Show "Add" button only on last item -->
            <button
                data-on:click="$phones.push({id: $nextId, number: ''}); $nextId++;"
                data-show="index === $phones.length - 1 && $phones.length < 3">
                Add
            </button>

            <!-- Show "Remove" button if more than one item -->
            <button
                data-on:click="$phones.splice(index, 1)"
                data-show="$phones.length > 1">
                Remove
            </button>
        </div>
    </template>
</div>

When to Use data-for

Server-Driven Philosophy

Datastar is server-driven. Most lists should render on the server with @foreach:

blade
<!-- ✅ Server-side rendering for static lists -->
@foreach($products as $product)
    <div>{{ $product->name }}</div>
@endforeach

The server knows your data, handles authorization, and delivers complete HTML. This is faster and simpler than client-side rendering.

When data-for Makes Sense

Use data-for when users modify the list on the client:

Dynamic Form Fields

blade
<div data-signals="{
    items: [{id: 1, value: ''}],
    nextId: 2
}">
    <template data-for__key.id="item, index in $items">
        <input data-bind="item.value" />
        <button data-on:click="$items.splice(index, 1)">Remove</button>
    </template>

    <button data-on:click="$items.push({id: $nextId, value: ''}); $nextId++;">
        Add Item
    </button>
</div>

Real-Time Updates

When the server sends updated lists via fragments, data-for updates the DOM efficiently.

When Not to Use data-for

Don't use data-for if:

  • The list doesn't change after initial render
  • Users don't interact with the list
  • The list is very large (hundreds of items)

For these cases, server-side @foreach is simpler and faster.

Integrating with Server Data

Transforming Backend Data

Your backend might structure data differently than data-for expects. Transform it in Blade:

blade
@php
    // Backend stores: ['555-1234', '555-5678']
    // data-for needs: [{id: 1, number: '555-1234'}, {id: 2, number: '555-5678'}]
    $phoneData = [];
    $nextId = 1;

    foreach ($contact->phone_numbers ?? [] as $phone) {
        $phoneData[] = ['id' => $nextId++, 'number' => $phone];
    }
@endphp

<form @signals([
    'phone_numbers' => $phoneData,
    'next_phone_id' => $nextId
])>
    <template data-for__key.id="phone in $phone_numbers">
        <input data-bind="phone.number" />
    </template>
</form>

Transform back in your controller before saving:

php
// Transform [{id, number}] back to ['number']
$phoneNumbers = array_map(
    fn($item) => $item['number'],
    signals('phone_numbers')
);

Common Patterns

Dynamic Phone Number Fields

blade
<div data-signals="{
    phoneNumbers: [{id: 1, number: ''}],
    nextPhoneId: 2
}">
    <template data-for__key.id="phone, index in $phoneNumbers">
        <div class="flex gap-2 mb-2">
            <input
                data-bind="phone.number"
                placeholder="Phone number"
                class="border px-3 py-2 rounded" />

            <button
                data-on:click="$phoneNumbers.push({id: $nextPhoneId, number: ''}); $nextPhoneId++;"
                data-show="index === $phoneNumbers.length - 1 && $phoneNumbers.length < 3"
                class="text-blue-500">
                Add
            </button>

            <button
                data-on:click="$phoneNumbers.splice(index, 1)"
                data-show="$phoneNumbers.length > 1"
                class="text-red-500">
                Remove
            </button>
        </div>
    </template>
</div>

Todo List

blade
<div @signals(['todos' => $todos])>
    <form data-on:submit__prevent="@postx('/todos')">
        <input data-bind="newTodo" placeholder="New todo" />
        <button type="submit">Add</button>
    </form>

    <template data-for__key.id="todo in $todos">
        <div>
            <input type="checkbox" data-bind="todo.done" />
            <span data-text="todo.title"></span>
            <button data-on:click="@deletex('/todos/' + todo.id)">Delete</button>
        </div>
    </template>
</div>

Product List

blade
<div @signals(['products' => $products])>
    <template data-for__key.id="product in $products">
        <div class="product-card">
            <h3 data-text="product.name"></h3>
            <p data-text="'$' + product.price"></p>
            <button data-on:click="@postx('/cart/add/' + product.id)">
                Add to Cart
            </button>
        </div>
    </template>
</div>

Nested Lists

blade
<div data-signals="{categories: [
    {
        name: 'Electronics',
        products: [
            {id: 1, name: 'Laptop'},
            {id: 2, name: 'Phone'}
        ]
    }
]}">
    <template data-for="category in $categories">
        <div>
            <h3 data-text="category.name"></h3>
            <template data-for__key.id="product in category.products">
                <div data-text="product.name"></div>
            </template>
        </div>
    </template>
</div>

Filtered Lists

blade
<div data-signals="{
    products: [...],
    searchQuery: ''
}">
    <input data-bind="searchQuery" placeholder="Search products..." />

    <template data-for__key.id="product in $products.filter(p =>
        p.name.toLowerCase().includes($searchQuery.toLowerCase())
    )">
        <div data-text="product.name"></div>
    </template>
</div>

Learn More