How to loop over data in a template
p-for iterates over slices, arrays, maps, and numeric ranges. Pair it with p-key for stable identity when the list can change. See the directives reference for the full syntax.
Iterate over a slice
Use the value-only form when the index is not needed:
<template>
<ul>
<li p-for="item in state.Items" p-key="item.ID">
{{ item.Name }}
</li>
</ul>
</template>
Access the index with the tuple form:
<li p-for="(idx, item) in state.Items" p-key="item.ID">
{{ idx + 1 }}. {{ item.Name }}
</li>
If the index is irrelevant, use _:
<li p-for="(_, item) in state.Items" p-key="item.ID">
{{ item.Name }}
</li>
Iterate over a map
<dl>
<div p-for="(key, value) in state.Config" p-key="key">
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
</div>
</dl>
Map iteration is deterministic in Piko. The generator emits a sorted-by-key range so the same map renders in the same order every request. This is intentional (predictable HTML, stable diffs in cache layers). For a different ordering, by value or by insertion for example, sort the entries inside Render and pass a slice of key-value structs to the template.
Note: This is a Piko-specific behaviour. Plain Go ranges over maps in randomised order; the template generator wraps the iteration with a key-sort so output is deterministic. See
for_emitter.go:446(emitDeterministicMapLoopWithContext).
Iterate over a numeric range
There is no range(start, end) builtin in PK expressions. Build the slice in Render and iterate that:
type Response struct {
Steps []int
}
func Render(r *piko.RequestData, props piko.NoProps) (Response, piko.Metadata, error) {
steps := make([]int, 0, 5)
for i := 1; i < 6; i++ {
steps = append(steps, i)
}
return Response{Steps: steps}, piko.Metadata{}, nil
}
<div p-for="i in state.Steps" p-key="i">
Step {{ i }}
</div>
Use p-key for mutable lists
p-key gives Piko a stable identity for each rendered element. Without it, updates that reorder or remove items may reuse the wrong DOM nodes, which breaks focus, animation, and form state.
Good keys include database IDs, slugs, and UUIDs. Avoid array indices, because they change when items move.
<li p-for="item in state.Items" p-key="item.ID">
For static lists that never change after render, you can omit p-key.
Nested loops
Nest p-for freely, and pair each inner loop with its own p-key:
<section p-for="group in state.Groups" p-key="group.ID">
<h2>{{ group.Name }}</h2>
<ul>
<li p-for="item in group.Items" p-key="item.ID">
{{ item.Name }}
</li>
</ul>
</section>
Empty-list fallback
Use p-if alongside the loop to show a placeholder when the list is empty:
<ul p-if="len(state.Items) > 0">
<li p-for="item in state.Items" p-key="item.ID">{{ item.Name }}</li>
</ul>
<p p-else>No items to display.</p>
Filter and sort before the template
Templates are for presentation, not transformation. Do filtering and sorting in Render:
func Render(r *piko.RequestData, props piko.NoProps) (Response, piko.Metadata, error) {
visible := make([]Item, 0, len(all))
for _, item := range all {
if !item.Hidden {
visible = append(visible, item)
}
}
sort.Slice(visible, func(i, j int) bool {
return visible[i].Created.After(visible[j].Created)
})
return Response{Items: visible}, piko.Metadata{}, nil
}
See also
- Directives reference for
p-for,p-key, andp-if. - How to conditionally render elements.
- Template syntax reference.