Lists and tables
Displaying a moving collection, without re-rendering the page. Two tools, and the choice between them hangs on one question: who writes the loop?The question that settles it
It is the base layer's COLLECTION_OWNER rule, and it is gated. Whoever writes the `for` decides the shape of the API — because the rendering happens there.| Who writes the loop | The shape of the API | Example |
|---|---|---|
| the author | a `with`, and the children inside | ui.each, filter_each, paginate_each |
| the component | a `render=` it calls per row | ui.datatable, ui.select |
| the client (JS) | neither one nor the other — the body is a template | ui.file_upload |
ui.each — the key, and why it counts
A bare `for` loop works… until an item carries client state. On re-render, idiomorph pairs the nodes by position: deleting the first shifts all the others, and the open accordion changes row. `ui.each` pushes a stable key per item, so the pairing follows IDENTITY.for task in ui.each(tasks, key="id"):
with ui.card():
ui.text(task.title)
ui.accordion(...) # keeps its state
# `key=` also accepts a callable:
for t in ui.each(tasks, key=lambda t: t.uuid):
ui.text(t.title)
filter_each — narrowing as you type
It sets a `bz-show` on every item, compared with what is typed. The filtering is therefore ENTIRELY client side: no round trip, and the items are HIDDEN, not removed — the DOM count does not move.class Filter(ClientState):
query: str = field(default="")
f = Filter()
ui.input(value=f.query, placeholder="Filter…")
for fruit in ui.filter_each(
FRUITS,
query=f.query,
text=lambda x: x, # what is searched
key=lambda x: x,
empty=lambda: ui.text("Nothing matches."),
):
ui.text(fruit)
`empty=` is rendered too, and hidden as long as a row matches — otherwise it would take a round trip to know there is nothing.paginate_each — a client-side window
The same mechanics: everything is rendered, only the current window is visible. `page` is a 1-indexed `ClientBinding` — so a `ui.pagination` drives it with no network.class View(ClientState):
page: int = field(default=1)
v = View()
for row in ui.paginate_each(ROWS, page=v.page,
per_page=20, key="id"):
ui.text(row.name)
ui.pagination(value=v.page, total=len(ROWS),
per_page=20)
Where the limit is
Everything is rendered: it is instant, and it only suits what fits in memory. Beyond a few hundred rows, `ui.datatable` is what you need — it paginates on the SERVER and renders only the page asked for.
ui.datatable — the loop belongs to the component
It sorts, filters, paginates, searches and exports. It is what decides which rows exist, so the author cannot write the `for` — they describe their columns, and pass a `render=` for the ones that are not text.class AccountsTable(DatatableState, scope="session",
addressable=True):
"""One subclass PER table: the state is keyed by
class, so sharing the base would share the sort
and the page."""
ui.datatable(
state=AccountsTable,
columns=[
ui.column("name", label="Name", sortable=True),
ui.column("city", label="City", filter=True),
ui.column("status", label="Status",
render=lambda v, row: ui.badge(v)),
],
rows=load, # a list, or a callable(Query)
exportable=True,
export_filename="accounts.csv",
row_key="id",
)
`addressable=True` gives the view an ADDRESS: sorting, paginating or searching rewrites the URL. The link shares, bookmarks, and the browser's arrows go back and forth.The parameter that changes everything
`rows=` accepts a list OR a callable that receives the `Query` (sort, page, search) and returns `(rows, total)`. It is the shape to take as soon as the source is a database: without it, one loads everything to display twenty.
The exact signatures:ui.* catalogue →