---
title: "Lists and Tables · RUAL Documentation"
description: "Render the documents of a storage search with web_foreach: keyed rows, an empty state, and the row key later actions use to reach exactly that row."
canonical: https://docs.rual.nl/web/lists
language: en
---

# Lists and Tables

[`web_foreach`](https://docs.rual.nl/block-types/web/web_foreach) renders a row template once per item of a list and gives every row a `data-key`, so a later action can replace or remove exactly that row. Wire its `html` into a custom pin of the render block and print it with triple braces.

## A table in three parts

A search, a row template and a page template. The row knows nothing about the page around it, which is what lets an action render the same row again later.

```
blueprint "Products admin"

flow page = web_page with {event: "admin/products", scopes: ["*loggedin"]} {
  found = search@products(query: query_sort_field[name](asc: true), limit: 100)
  let list = web_foreach(items: found.array, template = "{{> product-row}}", empty = "<tr><td colspan='3'>No products yet.</td></tr>")
  web_render(page.connection,
    rows: list.html,
    total: list.count,
    title = "Products",
    template = "{{> products-page}}"
  ) with {custom_pins: [{id: "rows", state: "rows", type: "value"}, {id: "total", state: "total", type: "number"}]}
}
```

```
<tr>
  <td>{{name}}</td>
  <td>{{price | money "EUR"}}</td>
  <td>
    <button type="button" data-action="edit">Edit</button>
    <button type="button" data-action="delete" data-confirm="Delete {{name}}?" data-optimistic="remove">Delete</button>
  </td>
</tr>
```

```
<h1>{{title}} ({{total}})</h1>
<table>
  <tbody id="products">{{{rows}}}</tbody>
</table>
```

Each ` ` arrives with its key, for example ` `. [Actions](https://docs.rual.nl/web/actions#patterns) shows the delete and edit flows those buttons run.

## The pins of web for each

It is a data block with no flow pins. It runs when something reads its `html`, so put a search in the flow before it.

| Pin | Is |
| --- | --- |
| `items` (in) | The list to render, such as the `array` of a [`function_search`](https://docs.rual.nl/block-types/storage/function_search). Required. |
| `template` (in) | The template of one row. Leave it unwired to use the template stored on the block. |
| `key` (in) | The item field that identifies a row, as a dotted path if needed. |
| `empty` (in) | A template to render instead when there are no items. |
| `data` (in) | An object whose fields every row can read too. Custom pins do the same, one value at a time. |
| `html` (out) | All rows joined, ready to print. |
| `count` (out) | How many rows were rendered, 0 for an empty list. |
| `success`, `error` (out) | A template error gives an empty `html`, `success` false and the message on `error`. The flow carries on, so check it if the list matters. |

## A row reads its own fields first

Inside a row, the item is the context, so a document's `name` is `{{name}}`. Behind it are the `data` fields, the custom pins and `web`. A row can read a shared value such as `{{currency}}` as long as the item has no field of that name.

The loop variables work as in any loop: `{{@index}}`, `{{@number}}`, `{{@first}}`, `{{@last}}`, `{{@odd}}`, `{{@even}}` and `{{@length}}`. For a list of plain values, the value itself is `{{.}}`.

## Every row gets a data-key

The key goes on the row's first element. This is the order it is decided in:

| Situation | The key is |
| --- | --- |
| The row's first element already has `data-key` | That one. It is kept. |
| The `key` pin names a field | That field's value. If the item has no such value, the row's position. |
| No `key` pin | The first of `_meta.guid`, `guid`, `id` and `key` the item has. A stored document therefore gets its guid. |
| None of those | The row's position, counting from 0. |

- **A row without a first element**, plain text or text in front of a tag, is wrapped in a ` `. Start table rows with their ` `, as a ` ` does not belong in a table.

- **Keys must be unique on the page.** An update finds its element by key, and the first match wins. Mind this when one page shows two lists of the same documents.

- **A position is only a fallback.** Positions move when a row is removed, so give rows that actions change a real key.

A button inside a row sends that row's key along, and the action receives it on its `key` out-pin. The closest element with a `data-key` decides.

## Print the rows with triple braces

The output is finished HTML, with every value in it already escaped. Normal braces would escape it again and show the tags to the visitor.

#### Write

` {{{rows}}} `

#### Not

` {{rows}} ` prints the markup as text.

## An empty list has its own template

With an empty list, or an `items` value that is not a list, the block renders the `empty` template with the shared data and `count` is 0. Without an `empty` template the result is an empty string. The empty template gets no key, as there is no item to take one from.

## Use web for each for rows that change

#### web_foreach

Rows an action changes later. It keys every row, its row template can be rendered again by an action, and it has `count` and `empty`.

#### {{for}} in the template

Anything that only shows: the items of an order, a list of tags, a list inside a row. It is less wiring, but the rows have no key unless you write one with `data-key="{{item.id}}"`.

## Load more is an insert

The list above stops after one `limit`. For the next rows, a button carries the offset and the action adds rows to the end of the list. Add a custom pin `next` with the value 100 to the render block, and put this below the table:

```
<div id="more">{{> load-more}}</div>

{{! The stored template load-more: }}
<button type="button" data-action="more" data-param-offset="{{next}}" data-loading-text="Loading">Load more</button>
```

```
flow more = web_action with {event: "more"} {
  let {offset} = more.form
  found = search@products(query: query_sort_field[name](asc: true), limit: 100, offset: offset)
  web_insert(
    target = "#products",
    html: web_foreach(items: found.array, template = "{{> product-row}}")
  )
  web_update(
    target = "#more",
    html: web_template(next: number_plus(offset, 100), template = "{{> load-more}}") with {custom_pins: [{id: "next", state: "next", type: "number"}]}
  )
}
```

[`web_insert`](https://docs.rual.nl/block-types/web/web_insert) appends by default, and [`web_update`](https://docs.rual.nl/block-types/web/web_update) gives the button its next offset. Once `found.hits` is below the limit there is nothing more to load: branch on that and answer with a [`web_remove`](https://docs.rual.nl/block-types/web/web_remove) of `#more` instead.

## Next steps

- [Actions](https://docs.rual.nl/web/actions): Replace, remove and insert the rows you just rendered.

- [Storages](https://docs.rual.nl/blueprints/storage): Searching, creating and updating the documents a page shows and its actions change.

- [Layouts and Partials](https://docs.rual.nl/web/layouts-and-partials): Store the row template so a page and an action share it.
