Skip to content
zabloo

Repeat

The node that turns one row into many. You author a single template, bind an array the game owns, and the SDK builds one copy per element.

primitivesince v1not focusable

A Repeat lets you write a row once and show it as many times as the data says. You give it a data path to an array the game owns and a single template node; the SDK builds one copy of that template per element and points each copy’s bindings at its own item. Picture a quest log: the document that ships carries one quest row, and the seven the player sees are that row instantiated against quests.open. You reach for it whenever how many things appear is the game’s business rather than the screen’s.

repeat-list.viewIR v1
Viewport

Viewport: 960 × 340

A Quests panel with three rows built from one template and numbered 0, 1 and 2 by their position in the bound array — Missing caravan in Dustfall, Wolves at the gate in North Road, and Ledger audit in Guild hall — each with its own Track button.
STATE
  • hover — off
  • pressed — off
  • focused — off
  • selected — off
  • disabled — off

Reported from the last painted frame

Not running — press Run to draw this on the GPU.

import { Button, Column, List, Row, Text } from "@zabloo/react";

export default function RepeatList() {
  return (
    <Column
      // `align: "stretch"` on the ROOT is what hands the panel the view's own
      // width; centring it instead would pin the panel to its content and no
      // change of viewport could reach it (ZAB-153).
      layout={{ grow: 1, justify: "center", align: "stretch", padding: "{space.6}" }}
      style={{ background: "{color.bg}" }}
    >
      <Column
        id="panel"
        layout={{ padding: "{space.5}", gap: "{space.4}", align: "stretch" }}
        style={{
          background: "{color.surface}",
          radius: "{radius.lg}",
          borderWidth: "{border.hairline}",
          borderColor: "{color.line}",
        }}
      >
        <Text style={{ color: "{color.text}", fontSize: "{text.lg}" }}>Quests</Text>

        <List
          items="quests.open"
          as="q"
          keyPath="id"
          layout={{ gap: "{space.2}", align: "stretch" }}
          // A slot, not a condition: the IR has no expressions to evaluate, so
          // "nothing here yet" is authored as a node that is in layout only
          // while the bound array is empty.
          empty={
            <Text style={{ color: "{color.faint}", fontSize: "{text.sm}" }}>
              No quests in your log
            </Text>
          }
        >
          {(q) => (
            <Row
              layout={{ height: 52, padding: "{space.2}", gap: "{space.3}", align: "center" }}
              style={{ background: "{color.slot}", radius: "{radius.md}" }}
            >
              {/* The element's position — the data has no such field. */}
              <Text
                bind={q("$index")}
                layout={{ width: 18 }}
                style={{ color: "{color.faint}", fontSize: "{text.xs}" }}
              />
              <Column layout={{ grow: 1, gap: 2 }}>
                <Text bind={q("name")} style={{ color: "{color.text}", fontSize: "{text.sm}" }} />
                <Text bind={q("area")} style={{ color: "{color.faint}", fontSize: "{text.xs}" }} />
              </Column>
              {/* One action for the whole list. Which row it came from travels
                  in the action context, not in a different action name. */}
              <Button
                variant="secondary"
                onClick="track"
                layout={{ width: 78, height: 32, justify: "center", align: "center" }}
              >
                <Text style={{ color: "{color.text}", fontSize: "{text.xs}" }}>Track</Text>
              </Button>
            </Row>
          )}
        </List>
      </Column>
    </Column>
  );
}
Three rows from one template. Open the IR tab and count what is actually in the envelope: a single row and an empty-state slot, nothing more. Then look at the numbers 0, 1 and 2 in the rendered rows — they appear in data that does not contain them, because the SDK knows each copy's position.

It is a node type rather than a prop on Container for the reason that has settled every similar question in this format: the SDK dispatches behavior by type, never by type and prop.

The two tables below are where the authoring conveniences land as structure. empty is a prop you write and a slot that ships; keyPath is called key once React is out of the picture and there is no key of its own to collide with.

Authoring props

There is no <Repeat> component: the slots are positional, and <List> and <Grid> are the one place that convention is written down. They share these props.

PropTypeDefaultDescription
itemsstringData path of the array, e.g. "shop.items".
asstring"item"Alias the template binds against.
keyPathstringabsentPath relative to the item naming its identity. Named keyPath because React owns key.
emptyReactNodeabsentShown while the array is empty, absent or not an array.
childrennode | (item) => nodeabsentThe item template — a single node.

On top of these, every component takes the node base props — id, visible, disabled, layout, style, states, transition, autofocus, clip — plus variant, which the theme resolves away at export time and which never appears in the IR. <List> adds axis; <Grid> adds columns, itemWidth and cell.

IR props

What ships to the game, after the authoring layer is gone.

PropTypeDefaultDescription
items{ bind: string }The bound array. Always a binding.
asstring"item"Alias the template binds against.
keystringabsentPath relative to the item naming its stable identity. Absent = positional.
childrenZNode[]absentchildren[0] = item template; children[1..] = empty state.
items is always a binding

A literal array here would put game data into the document, and the document carries structure. That line is what lets the same envelope be published once and filled in by every player’s own state.

The Repeat is the container

Its own layoutdirection, gap, padding, justify, align, wrap — lays the instances out, exactly as any container lays out its children. That is what lets <List> and <Grid> be authoring sugar over it rather than node types of their own.

Slots

  • children[0] is the template, emitted once and instantiated per element.
  • children[1..] is the empty state, in layout only while the bound value is an empty array, absent, or not an array at all — display:none semantics, the one hiding mechanism.

The empty state exists as a slot because “show Nothing here yet” would otherwise need a boolean expression over the data, and the IR has no expressions by design.

Binding inside the template

Paths in the template may start with the alias and are resolved against the current element:

PathResolves to
"item"the element itself — "shop.items.3"
"item.name""shop.items.3.name"
"item.$index"the element’s position — a number the data does not contain
"player.gold"itself: a path under no known alias is absolute

Scopes nest and the innermost alias wins, which is why the alias is declared rather than reserved: a nested list can still reach the outer element by its own name. It also means an alias shadows a data root of the same name — pick names that are not roots of your data.

Identity

key names a path relative to the item pointing at a stable field ("id", "meta.sku"). Identity is what makes updates stable: reordering a keyed array moves the SDK’s per-item state — focus, a checked Toggle, a scroll offset, an in-flight transition — with the item instead of leaving it pinned to a position. It is also what makes recycling and virtualization possible, and the focus is keyed by that identity too, so it survives a row being un-realized.

Without a key, identity is positional.

Behavior

States

disabled only, and it reaches every instance — one prop switches off a whole list of rows at once, however many the data turns out to hold.

Focusable

No, and nothing the player does reaches the Repeat itself. The instances keep their own focusability; the node that produced them takes no input.

Layout

Its own, applied to the instances. A <Grid> is this node with wrap and a cell width — the geometry is arithmetic, solved at authoring time, because v1 has no fractional dims.

Actions

None of its own — but the player presses a button inside a row → that button’s onClick fires → and the game hears the name plus an action context naming the row it came from. That context is the whole capability this node adds to an action.

Degradation

On an older SDK

On an older SDK the row shows once, blank, with the 'nothing here yet' message sitting underneath it.

As a Container: the template shows ONCE, static and unresolved — its bindings read nothing — alongside the empty state. The content survives; the repetition does not. A list of ten rows becomes one blank row, which is legible as a fallback rather than as a crash.

Nothing here yet

How the game hears this

One name for every row; which row it was rides along with it. A single onClick: "track" in the template becomes one button per quest, and the action arrives with a context — { path, key, index } — saying where it fired from. That is what lets a list of ten quests need one action name rather than ten, and what keeps the UI from knowing anything about quests.

The array itself moves through SetData: writing quests.open re-instantiates the template, and writing into one item moves the bindings inside that row.

using UnityEngine;
using Zabloo;

[RequireComponent(typeof(ZablooDocument))]
public sealed class QuestLog : MonoBehaviour
{
  ZablooDocument _doc;

  void Start()
  {
      _doc = GetComponent<ZablooDocument>();
      _doc.OnAction += OnZablooAction;

      // The rows are the game's own array. The document carries the template,
      // never the data — which is why this line is the whole "populate" step.
      _doc.SetData("quests.open", QuestBook.Open);
  }

  void OnDestroy()
  {
      if (_doc != null) _doc.OnAction -= OnZablooAction;
  }

  void OnZablooAction(string action)
  {
      // One action for every row. Which one it came from rides in the context.
      if (action == "track") TrackSelectedQuest();
  }
}

When the action fires from inside a Repeat item it carries an action context — the item's path, key and index — so the game can tell which row was pressed. The context is part of the format and the browser renderer delivers it; Unity's OnAction is Action<string> today and hands over the name alone, so a Unity game that needs the row reads it from its own state until that lands.

Composition

Repetition is one capability and scrolling is another, so a long list is a <List> inside a <ScrollView>.

  • <List> — use it for rows stacked in one direction: a quest log, an inbox, a shop’s stock.
  • <Grid> — use it when the cells are of a known width and should wrap: an inventory, a level select, a character roster.
  • empty — always fill it in, because “the array is empty” is a state the player will reach and there is no expression that could cover for you.
// List: the vertical case. The render-prop form gives you the item's paths
// as a function — it("name") → "it.name" — so renaming `as` follows through.
<List items="shop.items" as="it" keyPath="id" layout={{ gap: 8 }}
    empty={<Text>Nothing here yet</Text>}>
{(it) => (
  <Row layout={{ gap: 12, align: "center" }}>
    <Text bind={it("name")} />
    <Text bind={it("price")} />
    <Button onClick="buy"><Text>Buy</Text></Button>
  </Row>
)}
</List>

// Grid: the same Repeat, laid out as a wrapping row of sized cells.
// `columns` never reaches the IR — it is arithmetic done at authoring time,
// which is also why gap and padding must be numbers here, not tokens.
<Grid items="inventory.slots" columns={4} itemWidth={72} layout={{ gap: 8 }}>
{(slot) => <Text bind={slot("name")} />}
</Grid>

The template is a single node because children[0] is the template: wrap an item’s contents in a <Row> or a <Column>.