Skip to content

Loops & Lists ​

The <for> tag renders its children once per item of a bound C++ container, such as a std::vector.

The <for> tag ​

Name the bound collection with each, and choose the loop variable's name with as:

cpp
// Bound in the constructor:
std::vector<std::string> fruits = {"Apple", "Banana", "Cherry"};
Bind(fruits);
html
<ul>
  <for each="{fruits}" as="fruit">
    <li>{fruit} (index: {$index})</li>
  </for>
</ul>

Inside the loop body:

  • {fruit} interpolates the item, converted to a string.
  • {$index} interpolates the zero-based index. It is most useful for passing to a parameterized callback, e.g. <button onclick="RemoveItem({$index})"> (see Event Handlers).

When the collection changes — items added, removed, or mutated — the loop's rendered children are reconciled on the next digest.

This demo loops over a std::vector<std::string>, appending with a bound <input> and removing by {$index}:

Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// Looping over a std::vector<std::string> with <for>.
//
// {$index} gives the current position, which is how a row passes its identity
// to a parameterized callback.
//
// Try it: add a fruit, then remove one.
#include <cstddef>
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <vector>

using namespace rtxui;

class SimpleLoopApp : public Component<SimpleLoopApp> {
 public:
  std::vector<std::string> items = {"Apple", "Banana", "Cherry"};
  std::string new_fruit;

  void AddItem() {
    if (!new_fruit.empty()) {
      items.push_back(new_fruit);
      new_fruit.clear();
    }
  }
  void RemoveItem(std::string index) {
    size_t i = std::stoull(index);
    if (i < items.size()) {
      items.erase(items.begin() + i);
    }
  }

  std::string_view view = R"html(
      <div class="card">
        <h1>Loops</h1>
        <p>&lt;for each&gt; repeats its content per element, and $index gives the row's position to a handler.</p>

        <div class="add">
          <input value="{new_fruit}" placeholder="A fruit..." />
          <button onclick="AddItem">Add</button>
        </div>

        <div class="list">
          <for each="{items}" as="fruit">
            <div class="row">
              <span class="grow">{fruit}</span>
              <button class="remove" onclick="RemoveItem({$index})">✕</button>
            </div>
          </for>
        </div>
      </div>

      <style>
        self {
          --border: rgb(48, 54, 61);
          --muted: rgb(139, 148, 158);
          --accent: rgb(88, 166, 255);

          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          color: rgb(230, 237, 243);
        }
        .card {
          border: tall;
          border-color: var(--border);
          background-color: rgb(22, 27, 34);
          padding: 1 3;
          width: 50;
        }
        h1 {
          color: var(--accent);
        }
        p {
          color: var(--muted);
        }
        .add {
          display: flex;
          gap: 1;
          margin-bottom: 1;
        }
        input {
          flex-grow: 1;
          border: tall;
          border-color: var(--border);
          background-color: rgb(13, 17, 23);
          padding: 0 1;
        }
        input:focus {
          border-color: var(--accent);
        }
        button {
          border: tall;
          border-color: var(--border);
          background-color: rgb(13, 17, 23);
          padding: 0 1;
        }
        button:hover, button:focus {
          border-color: var(--accent);
          color: var(--accent);
        }
        .list {
          border: round;
          border-color: var(--border);
        }
        .row {
          display: flex;
          gap: 2;
          padding: 0 1;
        }
        .row:hover {
          background-color: rgb(33, 38, 45);
        }
        .grow {
          flex-grow: 1;
        }
        .remove {
          border: none;
          background-color: transparent;
          color: rgb(248, 81, 73);
        }
        .remove:hover, .remove:focus {
          background-color: rgb(73, 32, 36);
        }
      </style>
    )html";

  SimpleLoopApp() {
    Bind(items);
    Bind(new_fruit);
    Bind(AddItem);
    Bind(RemoveItem);
  }
};

int main() {
  auto app = Ref<SimpleLoopApp>::New();
  Screen screen(app);
  screen.Loop();
  return 0;
}

Keyed loops ​

By default, collection children reconcile by position: the DOM node at index i is reused for the i-th item. When items reorder or shift, element-local state (keyboard focus, scroll offsets, active CSS transitions) stays locked to the index rather than tracking the item.

Give the loop a key to identify items instead:

html
<for each="{tasks}" as="task" key="{task.id}">
  <div tabindex="0">{task.name}</div>
</for>

The key is interpolated per item, exactly like the loop body, so it can be any expression that names the item — an id field is the usual choice. On the next digest each item's existing elements are moved to the item's new position and reused there, carrying their state with them. Focus stays on the task the user had focused, even if it shifts three rows down.

Keys must be unique within the loop, and stable across frames: keying by {$index} is the same as not keying at all. A loop without key keeps the position matching described above, which stays the cheaper option for a list that never reorders.

What a change renders ​

When the component renders again, an item whose interpolations all give what they gave last time (its own value, {$index}, and anything else it reads) is left as it is: changing one item of a long list renders that item only. Its components still pick up their own changes.

An item renders every time when its output depends on more than what it interpolates: when it holds a nested <for> or a <slot>, or passes a value to a component's bound property, such as <input value="{item.name}">.

Long lists: virtual ​

A loop renders every item, and every item costs something each frame: a few thousand are fine, a hundred thousand are not. Inside a scroll container, mark the loop virtual to render only the items around what the container shows:

html
<div class="log">
  <for each="{lines}" as="line" virtual="">
    <div>{$index}: {line}</div>
  </for>
</div>
<style>
  .log {
    height: 20;
    overflow-y: scroll;
  }
</style>

The loop renders the items in view and a screenful more on either side, and two empty blocks stand in for the rest, so the scroll range, the scrollbar and {$index} are those of the whole collection. When scrolling moves past what was rendered, the loop renders again at the new position; scrolling within a screenful needs no render at all. A 100,000-item list costs about what one screen of items does.

  • Every item is assumed to be the same height: one row, or item-height="N" rows. An item of a different height puts the rows the container shows out of step with the items rendered.
  • The scroll container is the nearest ancestor with an overflow-y other than visible. A virtual loop without one is reported as a diagnostic.
  • Only the rendered items are in the DOM: an item scrolled away loses its element state (focus, its own scroll), and Tab does not reach the items not rendered. Scrolling the container brings them in.
  • Spacers are block-level, so the container should lay out its items as a column: a block, or a flex-direction: column flex box.
Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// A virtual list: 100,000 log lines.
//
// `<for ... virtual="">` inside a scroll container renders only the lines
// around what the container shows, with two spacers standing in for the rest,
// and renders again as scrolling moves past them. A frame then costs what a
// screenful of lines does, however long the log.
//
// Try it: scroll with the mouse wheel, or Tab to the log and use the arrows,
// PageUp/PageDown and Home/End.
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <vector>

using namespace rtxui;

struct Line {
  std::string level;
  std::string text;
  bool operator==(const Line&) const = default;
};

// What a template reads of a Line: {line.level} and {line.text}.
void Fields(const Line& line, FieldSink& out) {
  out("level", line.level);
  out("text", line.text);
}

class LogViewer : public Component<LogViewer> {
 public:
  std::vector<Line> lines;

  LogViewer() {
    static constexpr const char* kLevels[] = {"INFO", "INFO",  "INFO", "WARN",
                                              "INFO", "DEBUG", "ERROR"};
    for (int i = 0; i < 100000; ++i) {
      lines.push_back(
          {kLevels[i % 7], "request " + std::to_string(i) + " served in " +
                               std::to_string(3 + (i * 37) % 250) + "ms"});
    }
    Bind(lines);
  }

  std::string_view view = R"html(
      <div class="app">
        <h1>Log viewer — 100,000 lines</h1>
        <div class="log" tabindex="0">
          <for each="{lines}" as="line" virtual="">
            <div class="line">
              <span class="index">{$index}</span>
              <span class="{line.level}">{line.level}</span>
              <span>{line.text}</span>
            </div>
          </for>
        </div>
      </div>

      <style>
        self {
          width: 100%;
          height: 100%;
          background-color: rgb(13, 17, 23);
          color: rgb(201, 209, 217);
        }
        .app {
          display: flex;
          flex-direction: column;
          height: 100%;
          padding: 0 1;
        }
        h1 {
          color: rgb(88, 166, 255);
          font-weight: bold;
        }
        .log {
          flex-grow: 1;
          overflow-y: scroll;
          border: tall;
          border-color: rgb(48, 54, 61);
          background-color: rgb(22, 27, 34);
        }
        .log:focus {
          border-color: rgb(88, 166, 255);
        }
        .line {
          display: flex;
          gap: 1;
        }
        .index {
          width: 6;
          text-align: right;
          color: rgb(110, 118, 129);
        }
        .INFO {
          color: rgb(63, 185, 80);
          width: 5;
        }
        .DEBUG {
          color: rgb(139, 148, 158);
          width: 5;
        }
        .WARN {
          color: rgb(210, 153, 34);
          width: 5;
        }
        .ERROR {
          color: rgb(248, 81, 73);
          width: 5;
          font-weight: bold;
        }
      </style>
    )html";
};

int main() {
  auto app = Ref<LogViewer>::New();
  Screen screen(app);
  screen.SetInline(false);
  screen.Loop();
  return 0;
}

Collections of structs ​

A collection of plain values stringifies each item directly. For a collection of structs, say once what a template reads of the type, with a Fields() function next to it. Bind(tasks) then needs nothing more:

cpp
struct Task {
  std::string name;
  bool completed;
};

// What a template reads of a Task: {task.name} and {task.status}.
void Fields(const Task& task, FieldSink& out) {
  out("name", task.name);
  out("status", task.completed ? "Done" : "Pending");
}

class TaskList : public Component<TaskList> {
 public:
  std::vector<Task> tasks{{"Write docs", true}, {"Ship", false}};
  TaskList() { Bind(tasks); }

  std::string_view view = R"html(
    <ul>
      <for each="{tasks}" as="task"><li>{task.name} — {task.status}</li></for>
    </ul>
  )html";
};
  • out(name, value) converts the value to text as {name} would: no std::to_string needed.
  • Declare Fields() in the same namespace as the type, before the component that binds the collection: it is found the way operator<< is.
  • A field the type does not give reads as empty.

Fields that depend on the component ​

Fields() sees only the item. When what a template shows also depends on the component, like a row hidden by a filter, give the binding its own fields, with a const member function of the same shape. It takes precedence over the type's Fields():

cpp
std::string filter = "all";

TaskList() { Bind(tasks, &TaskList::TaskFields); }

void TaskFields(const Task& task, FieldSink& out) const {
  out("name", task.name);
  out("hidden", filter == "done" && !task.completed ? "hidden" : "");
}

A lambda of the same shape works too: Bind(tasks, [](const Task& task, FieldSink& out) { ... }). BindCollection("name", &collection, fields) is the same, for a template name that differs from the member name.

Changes ​

A collection is compared with its last render to find what changed. When the type has an operator==, items are compared with it, which is the cheapest. Without one, the fields from Fields() or the binding are compared instead.

With C++26 reflection ​

When your application is compiled with C++26 static reflection (for example GCC 16 with -std=c++26 -freflection), a struct without Fields() exposes its members under their own names: Bind(tasks) alone makes {task.name} work. A Fields() function, or the binding's own fields, still decide when there is one: to rename, format or compute a field such as status above.

This demo loops over a std::vector<Task> described by Fields(), toggling each item's completed field from the template:

Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// Looping over a collection of structs.
//
// A mapper exposes each struct's fields to the template, which reads them with
// dot notation on the loop variable.
//
// Try it: click a status to toggle it, then add or remove a task.
#include <cstddef>
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <vector>

using namespace rtxui;

struct Task {
  std::string name;
  bool completed;
  bool operator==(const Task& other) const = default;
};

// What a template reads of a Task: {task.name} and {task.status}.
void Fields(const Task& task, FieldSink& out) {
  out("name", task.name);
  out("status", task.completed ? "done" : "todo");
}

class ComplexLoopApp : public Component<ComplexLoopApp> {
 public:
  std::vector<Task> tasks = {
      {"Set up the project", true},
      {"Write the loops", true},
      {"Write the docs", false},
  };
  std::string new_task_name;

  void AddTask() {
    if (!new_task_name.empty()) {
      tasks.push_back({new_task_name, false});
      new_task_name.clear();
    }
  }
  void ToggleTask(std::string index) {
    size_t i = std::stoull(index);
    if (i < tasks.size()) {
      tasks[i].completed = !tasks[i].completed;
    }
  }
  void RemoveTask(std::string index) {
    size_t i = std::stoull(index);
    if (i < tasks.size()) {
      tasks.erase(tasks.begin() + i);
    }
  }

  std::string_view view = R"html(
      <div class="card">
        <h1>Looping over structs</h1>
        <p>A Fields() function names what the template reads of each Task. Click a status to toggle it.</p>

        <div class="add">
          <input value="{new_task_name}" placeholder="A task..." />
          <button onclick="AddTask">Add</button>
        </div>

        <div class="list">
          <for each="{tasks}" as="task">
            <div class="row">
              <button class="status {task.status}" onclick="ToggleTask({$index})">{task.status}</button>
              <span class="grow">{task.name}</span>
              <button class="remove" onclick="RemoveTask({$index})">✕</button>
            </div>
          </for>
        </div>
      </div>

      <style>
        self {
          --border: rgb(48, 54, 61);
          --muted: rgb(139, 148, 158);
          --accent: rgb(88, 166, 255);

          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          color: rgb(230, 237, 243);
        }
        .card {
          border: tall;
          border-color: var(--border);
          background-color: rgb(22, 27, 34);
          padding: 1 3;
          width: 56;
        }
        h1 {
          color: var(--accent);
        }
        p {
          color: var(--muted);
        }
        .add {
          display: flex;
          gap: 1;
          margin-bottom: 1;
        }
        input {
          flex-grow: 1;
          border: tall;
          border-color: var(--border);
          background-color: rgb(13, 17, 23);
          padding: 0 1;
        }
        input:focus {
          border-color: var(--accent);
        }
        button {
          border: tall;
          border-color: var(--border);
          background-color: rgb(13, 17, 23);
          padding: 0 1;
        }
        button:hover, button:focus {
          border-color: var(--accent);
          color: var(--accent);
        }
        .list {
          border: round;
          border-color: var(--border);
        }
        .row {
          display: flex;
          gap: 2;
          padding: 0 1;
        }
        .row:hover {
          background-color: rgb(33, 38, 45);
        }
        .grow {
          flex-grow: 1;
        }
        .remove {
          border: none;
          background-color: transparent;
          color: rgb(248, 81, 73);
        }
        .remove:hover, .remove:focus {
          background-color: rgb(73, 32, 36);
        }
        .status {
          border: none;
          width: 6;
        }
        .done {
          color: rgb(63, 185, 80);
        }
        .todo {
          color: rgb(210, 153, 34);
        }
      </style>
    )html";

  ComplexLoopApp() {
    Bind(tasks);
    Bind(new_task_name);
    Bind(AddTask);
    Bind(ToggleTask);
    Bind(RemoveTask);
  }
};

int main() {
  auto app = Ref<ComplexLoopApp>::New();
  Screen screen(app);
  screen.Loop();
  return 0;
}