docs

Binding Lists

Bind C++ containers to HTML templates, synchronize rows, and handle row actions.

On this page

You can repeat an HTML template for each item in a C++ container such as an inventory, a scoreboard, or a chat log. Calling Sync() in native code keeps the rows in step with the container as items come and go.

Example Data

The examples on this page give the player from Showing Data in Markup a list of items keyed by id. The row type uses an explicit schema to declare an action— see Describing Types with Schemas:

C++
struct Item {
  int64_t id = 0;
  std::string name;
  int count = 1;
  bool equipped = false;

  static constexpr auto schema = dd::Schema(
      dd::Field("name", &Item::name),
      dd::Field("count", &Item::count),
      dd::Field("equipped", &Item::equipped),
      dd::Action("drop"));  // fired from a row
};

struct Player {
  std::string name;
  std::vector<Item> items;

  static constexpr auto schema = dd::Schema(
      dd::Field("name", &Player::name),
      dd::List("items", &Player::items, &Item::id));  // keyed by id
};

Native code sets initial items and binds the player:

C++
Player player { .name = "Ava",
                .items = { { .id = 1, .name = "Potion", .count = 3 },
                           { .id = 2, .name = "Sword", .equipped = true } } };

// This instance will be exposed to markup as "player.<field>..."
dd::Binding binding = ctx.Bind("player", player);

Repeating a Template

Set ul-for on a container element with the path to the list, and put a <template> inside for one row:

HTML
<ul ul-for="player.items">
  <template>
    <li ul-class-equipped="equipped">{{name}} x{{count}}</li>
  </template>
</ul>

Inside the template, a bare field name reads the current row's field— write {{name}} instead of player.items.name. Every binding from Showing Data in Markup works inside a row.

The page displays one element per item after the hidden template:

HTML
<ul ul-for="player.items">
  <template>...</template>
  <li>Potion x3</li>
  <li class="equipped">Sword x1</li>
</ul>

🚧 Template Rules

The template must have exactly one root element. The container holds only the template and its generated rows— any other child elements are removed. The template remains the container's first child, so :first-child never matches a row (use :first-of-type instead).

👍 Table Rows

Put ul-for on the <tbody> and make the template's root a <tr>. Browsers move or discard a <tr> that is not inside a table part.

Reading Outside the Row

To read from a bound model outside the row, use its full path:

HTML
<!-- Shows: Potion (carried by Ava) -->
<li>{{name}} (carried by {{player.name}})</li>

Changing the List

Modify the container in native code and call Sync()— the page adds, removes, and moves rows to match:

C++
player.items.push_back({ .id = 3, .name = "Shield" });  // adds a row
std::erase_if(player.items,
              [](const Item& item) { return item.id == 1; });  // removes Potion
std::ranges::sort(player.items, {}, &Item::name);  // moves rows
ctx.Sync();  // the page shows all three changes at once

Keys

Passing a key member to dd::List (here id) gives each row a unique identity. Without a key, rows match by position:

With a Key Without a Key
When rows reorder Each row's elements move with it, so focus and CSS transitions stay with the item Rows stay in place and their contents change
Use for Lists that reorder, filter items, or fire row actions Lists that only grow at the end (such as a chat log)

Row Actions

To fire an action from a row, use ul-on: with the bare action name inside the template:

HTML
<ul ul-for="player.items">
  <template>
    <li>{{name}} <button ul-on:click="drop">Drop</button></li>
  </template>
</ul>

Register the handler in native code using the list name and action name joined by a dot (items.drop):

C++
// Runs at the next Sync with the row whose button was clicked.
binding.OnAction<"items.drop">([&](const Item& item) {
  std::erase_if(player.items, [&](const Item& i) { return i.id == item.id; });
});

The row-type handler receives a row rebuilt from its schema fields as of the last Sync()— any member outside the schema keeps its default value.

If you only need to identify the row, take an int64_t for the row's key instead (or the row's position in a list without keys).

For other handler forms, see Handling Actions.

To use the row-type form, the row type must be default-constructible. Every field in its schema must read a data member that is a bool, a number, an enum, a string (std::string or String), a StyleValue, or a Color. Action entries in the schema are fine too— the example Item struct qualifies.

If a row type has a list, a nested or nullable object, a Var, a getter or lambda accessor, or a std::string_view member, the row-type form doesn't compile. Take the row key (int64_t, or std::string_view for string keys) or a dd::Value instead.

🚧 Row Actions Without Keys

In a list without a key, a row action finds its row by position. If native code adds, removes, or moves items before the next Sync(), the handler receives whichever item now sits at that position— the action reaches the wrong row. Always give the list a key when using row actions (the library warns about row actions on unkeyed lists when developer mode is on).

📘 Form Controls in Rows

Using ul-value inside a row displays the value but doesn't send changes back. Only fields of the bound model itself send changes back.

Lists Inside Rows

A row type can hold its own list. Nest another element with ul-for inside the outer <template> to display the nested items.

Inside the nested template, bare field names resolve to the innermost row first.

Row actions only reach one list level deep (items.drop), so rows in a nested list can't have their own actions. An outer row's action (eg, drop) won't fire from inside the nested template either— only root-level actions on the bound model will run (developer mode warns at page compile if markup binds an undeliverable action).

A row type that holds a list can't use the row-type handler form. For its own row actions, take the row key or a dd::Value instead.