Array Bindings and Virtual Lists
The most likely source of complexity and performance issues in applications is
displaying large lists or grids of objects. tosijs provides robust support
for handling this efficiently.
.tosi.listBinding()
The simplest way to bind an array is with .tosi.listBinding(). It takes a template builder function that receives
the elements proxy and a placeholder item for bindings:
import { elements, tosi } from 'tosijs'
const { listBindingExample } = tosi({
listBindingExample: {
array: ['this', 'is', 'an', 'example']
}
})
const { h3, ul } = elements
preview.append(
h3('binding an array of strings'),
ul(
...listBindingExample.array.tosi.listBinding(({li}, item) => li(item))
)
)
And here is the same thing at scale: a few thousand emoji, in a virtual list, so only the rows on screen exist. Virtualized Lists below explains the options.
import { elements, tosi, scrollListItemIntoView } from 'tosijs'
const request = await fetch(
'https://raw.githubusercontent.com/tonioloewald/emoji-metadata/master/emoji-metadata.json'
)
const emojiData = await request.json()
const { emojiListExample } = tosi({
emojiListExample: {
array: emojiData
}
})
const { div, button } = elements
const emojiTable = div(
{
class: 'emoji-table'
},
...emojiListExample.array.tosi.listBinding(({div, span}, item) =>
div(
{
class: 'emoji-row',
tabindex: 0,
},
span({ textContent: item.chars, class: 'graphic' }),
span({ textContent: item.name, class: 'no-overflow' }),
span({ textContent: item.category, class: 'no-overflow' }),
span({ textContent: item.subcategory, class: 'no-overflow' })
),
{
value: emojiListExample.array,
idPath: 'name',
virtual: {
height: 30,
rowChunkSize: 3
},
}
)
)
const scrollTo = (name) => {
const item = emojiData.find(e => e.name === name)
if (item) scrollListItemIntoView(emojiTable, item)
}
preview.append(
emojiTable,
div(
{ class: 'scroll-buttons' },
button('rocket', { onClick: () => scrollTo('rocket') }),
button('flag: Finland', { onClick: () => scrollTo('flag: Finland') }),
button('pile of poo', { onClick: () => scrollTo('pile of poo') }),
),
)
.scroll-buttons {
display: flex;
justify-content: center;
gap: 4px;
padding: 4px;
}
.emoji-table {
height: calc(100% - 36px);
overflow: auto;
}
.emoji-row {
display: grid;
grid-template-columns: 50px 300px 200px 200px;
align-items: center;
height: 30px;
overflow-x: hidden;
}
.emoji-row:nth-child(3n) {
background: #f002;
}
.emoji-row:nth-child(3n+2) {
background: #00f2;
}
.emoji-row > .graphic {
font-size: 20px;
justify-self: center;
}
.emoji-row > * {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
listBinding(templateBuilder, options?) => [ElementProps, HTMLTemplateElement]
type ListTemplateBuilder<U = any> = (elements: ElementsProxy, item: U) => HTMLElement
The template builder receives two arguments: the elements proxy (destructure
the tags you need) and a placeholder proxy for the array item. Property access
on the placeholder creates relative bindings (^.name, ^.score, etc.)
automatically.
Spread the result into a container element — it returns an [ElementProps, HTMLTemplateElement]
tuple. The props (containing bindList) are applied to the container and the
<template> becomes a child; the ListBinding constructor then finds and removes
the <template>, extracting its children as stamp templates. The <template>
element never appears in the live DOM — it's consumed during initialization.
Options
Pass options as the second argument:
...items.tosi.listBinding(({div}, item) => div(item.name), {
idPath: 'id', // unique id field for surgical updates
virtual: { height: 30 } // virtualize for large lists
})
bindList + template — the low-level form
.tosi.listBinding() is sugar over bindList, and no bind* shortcut is
deprecated — 1.9.0 deprecated some and 1.9.1 reversed it, because the shortcut
is the only form that takes a path STRING (disabled: 'app.busy' sets an
always-truthy string and permanently disables the control). Neither
bindList nor bindValue has a plain-prop equivalent either (one needs a <template>
sibling and options, the other is two-way), so both stay.
div( // container element
{
bindList: {
value: boxed.path.to.array, // OR 'path.to.array'
idPath: 'id' // (optional) path to unique id of array items
}
},
template( // template for the repeated item
div( // repeated item should have a single root element
... // whatever you want
span({
bind: { value: '^.foo.bar', binding: 'text' }
// binds a given array member's `foo.bar`
// '^' refers to the array item itself
})
)
)
)
You rarely need this form — .tosi.listBinding() is more concise and type-safe.
Even better, templateBuilder() is passed the elements proxy and a placeholder BoxedProxy of
the array's type, supporting autocompletion of property names within the template.
id-paths
id-paths are a wrinkle in tosijs paths specifically there to make list-binding more efficient.
This is because in many cases you will encounter large arrays of objects, each with a unique id somewhere, e.g. it might be id or uid
or even buried deeper…
const { messages } = tosi({ messages: [
{
id: '1234abcd',
title: 'hello',
body: 'hello there!'
},
…
] })
Instead of referring to the first item in messages as messages[0] it can be referred to
as messages[id=1234abcd], and this will retrieve the item regardless of its position in messages.
Specifying an idPath in a list-binding will allow the list to be more efficiently updated.
It's the equivalent of a key in React, the difference being that it's optional and
specifically intended to leverage pre-existing keys where available.
When to use idPath
Always use idPath for arrays of objects unless you have a very simple case.
Without idPath:
- Bindings use index-based paths like
list[0].name - If items are reordered (sort, splice, etc.), bindings point to wrong items
- Fine-grained property updates may not reach the correct DOM elements
- The list binding will work, but inefficiently - often recreating elements
Skipping idPath is fine when:
- The array is simple scalars (
['apple', 'banana', 'cherry']) - The list is static and never reorders
- You always replace items wholesale, never updating properties
Id values must not contain
]characters. Id-paths are encoded aslist[id=value]in path strings, so a]in the value will break the path parser. Characters like[,=, and.are fine. If your ids could contain], use a sanitized field or a separate numeric id.
Surgical Updates with id-paths
When you specify an idPath, something remarkable happens: changes to individual
item properties trigger surgical DOM updates without re-rendering the entire list.
Here's how it works:
- When a list binding is created with an
idPath, tosijs registers that array path - When you modify an item property (e.g.,
list[0].color = 'red'), tosijs detects this is inside an array item - It automatically synthesizes an equivalent id-path touch (e.g.,
list[id=123].color) - Bindings registered with id-path notation receive the update
This means you can update one property on one item in a list of millions, and only that single DOM element updates. No diffing, no virtual DOM, no reconciliation - just direct, surgical updates.
To see this in action: Open your browser's DevTools, enable "Paint flashing" (in Chrome: DevTools → More tools → Rendering → Paint flashing), and watch the virtualized grid example below. Only the cells whose values actually change will flash.
Nested lists
A list binding can live inside another list's item template — the inner list's
relative path (^.subs) resolves against each outer item. This depends on
cloning a <template> that itself contains a <template>, which only behaves
correctly in a real browser (jsdom/happy-dom put cloned children in the wrong
place), so it is verified in-browser here:
import { tosi, elements, boxed, updates } from 'tosijs'
const { div, span, template } = elements
test('a nested list binding renders each inner item', async () => {
tosi({
nestedListDoc: {
groups: [
{ gid: 'g1', subs: [{ sid: 's1', label: 'one' }, { sid: 's2', label: 'two' }] },
{ gid: 'g2', subs: [{ sid: 's3', label: 'three' }] },
],
},
})
const container = div(
template(
div(
div(
{
class: 'subs',
bind: { value: '^.subs', binding: 'list', options: { idPath: 'sid' } },
},
template(span({ bind: { value: '^.label', binding: 'text' } }))
)
)
),
{
bind: {
value: boxed.nestedListDoc.groups,
binding: 'list',
options: { idPath: 'gid' },
},
}
)
preview.append(container)
await updates()
const labels = [...container.querySelectorAll('span')].map((s) => s.textContent)
expect(labels).toEqual(['one', 'two', 'three'])
})
Iterating and Searching Arrays
Proxied arrays have specific semantics around which iteration patterns
yield proxied vs. raw items — for...of and find() give you proxies
(mutations are observed), while forEach()/map()/filter() pass raw
items to callbacks (mutations are silent). See iterating proxied
arrays under tosi for the full breakdown.
The relevant pattern for list bindings: when you want to update many
items at once, the most efficient idiom is to mutate the raw array
directly and then touch() the array path once — a single observer
wave instead of one per item:
// Mutate behind the proxy's back…
list.forEach(item => {
item.score = computeScore(item)
})
// …then tell the proxy you're done.
touch('path.to.list')
This trivial example would work fine via for...of too, but for thousands
of mutations the batched-touch form is dramatically cheaper. The path
string is used here deliberately: you've already bypassed the proxy, so
you notify the registry by path rather than reaching for a proxy reference.
Virtualized Lists
The real power of list bindings comes from their support for virtualizing lists.
A list of more than 10 items must virtualize or say why. Without virtual
or a nonVirtualReason (e.g. { nonVirtualReason: 'fixed list of 12 months' })
it logs one warning naming the list, its size and both fixes. See
bindings for the details.
...emojiListExample.array.tosi.listBinding(({ div }, item) => div(item.name), {
idPath: 'name',
virtual: {
height: 30,
rowChunkSize: 3,
},
})
A virtual list needs somewhere to scroll. The bound element is the scroll
container, so give it a height and overflow: auto (the example below uses
height: calc(100% - 36px); overflow: auto). An element that simply grows to
fit its content has nothing to virtualize against. To scroll with the page
instead, see Window Scroll Container below.
The full set of virtual options is height, width, minHeight,
rowChunkSize, visibleColumns, itemsPerRow and scrollContainer; each is
covered in its section on this page.
Simply add a virtual property to the list-binding specifying the row height
and the list will be virtualized (meaning that only visible elements will be rendered,
missing elements being replaced by a single padding element above and below the list).
For variable-height items, add minHeight — see Variable-Height Items below.
You can (optionally) specify rowChunkSize to virtualize the list in chunks of rows to allow
consistent :nth-child() styling.
Now you can trivially bind an array of a million objects to the DOM and have it scroll at 120fps.
The emoji table near the top of this page is exactly this: virtual: { height: 30, rowChunkSize: 3 } on a container with a fixed height and overflow: auto.
Virtualized Grids
You can virtualize a grid by styling the padding elements (with class .virtual-list-padding)
to have the correct column span. (You can also just specify a fixed width for your list.)
This example creates 2000 cells with random text colors, then updates 10% of them
randomly every 500ms. Enable paint flashing to see the surgical updates in action.
Note how rowChunkSize: 2 allows consistent row shading via :nth-child().
import { elements, tosi, scrollListItemIntoView } from 'tosijs'
// Generate random saturated colors
const randomColor = () => {
const h = Math.floor(Math.random() * 360)
return `hsl(${h}, 80%, 45%)`
}
const list = []
for (let i = 0; i < 2000; i++) {
list.push({ id: i, color: randomColor() })
}
const { bigBindTest } = tosi({
bigBindTest: list
})
// Update 10% of items randomly every 500ms
setInterval(() => {
const count = Math.floor(list.length * 0.1)
for (let i = 0; i < count; i++) {
const idx = Math.floor(Math.random() * list.length)
bigBindTest[idx].color = randomColor()
}
}, 500)
const { div, button } = elements
const grid = div(
{
class: 'virtual-grid-example',
},
...bigBindTest.tosi.listBinding(
({div}, item) => div({
class: 'cell',
textContent: item.id,
bind: {
value: item.color,
binding: {
toDOM(el, color) {
el.style.color = color
}
}
}
}),
{
idPath: 'id',
virtual: {
height: 40,
visibleColumns: 7,
rowChunkSize: 2,
}
}
)
)
preview.append(
grid,
div(
{ class: 'scroll-buttons' },
button('Scroll to #17', {
onClick() {
scrollListItemIntoView(grid, list[17])
}
}),
button('Scroll to #1000', {
onClick() {
scrollListItemIntoView(grid, list[1000])
}
}),
button('Scroll to #1984', {
onClick() {
scrollListItemIntoView(grid, list[1984])
}
})
),
)
.scroll-buttons {
display: flex;
justify-content: center;
gap: 4px;
padding: 4px;
}
.virtual-grid-example {
height: calc(100% - 36px);
width: 100%;
overflow-y: auto;
display: grid;
grid-template-columns: 14% 14% 14% 14% 14% 14% 14%;
}
.virtual-grid-example .virtual-list-padding {
grid-column: 1 / 8;
}
.virtual-grid-example .cell {
height: 40px;
line-height: 40px;
text-align: center;
font-weight: bold;
transition: color 0.3s ease;
}
.virtual-grid-example .cell:nth-child(14n+2),
.virtual-grid-example .cell:nth-child(14n+3),
.virtual-grid-example .cell:nth-child(14n+4),
.virtual-grid-example .cell:nth-child(14n+5),
.virtual-grid-example .cell:nth-child(14n+6),
.virtual-grid-example .cell:nth-child(14n+7),
.virtual-grid-example .cell:nth-child(14n+8) {
background: #0001;
}
Multi-Column Grids with itemsPerRow
When itemsPerRow is set, the template builder is called N times per array item,
with a columnIndex argument (0..N-1). This produces a flat CSS grid where every
cell is a direct child of the container — which means position: sticky works
naturally for pinned rows and columns.
The container automatically gets:
- Class
tosi-virtual-grid - CSS variable
--tosi-columns(equal toitemsPerRow) display: gridwithgrid-template-columns: repeat(var(--tosi-columns), 1fr)
Override grid-template-columns in your own CSS to set custom column widths.
Pinned Header and Footer Rows
Any elements before the template in the container are treated as headers;
any after it are footers. Both are preserved across list updates, and
because they're siblings of the data cells in the same grid container,
position: sticky works for pinning rows to the top or bottom:
div(
...headerCells, // sticky top: 0
...items.tosi.listBinding(builder, options), // virtual data rows
...footerCells, // sticky bottom: 0
)
The template's position determines where list items and virtual padding are inserted — between the headers and footers.
Limitation: header and footer rows must be static content. They cannot themselves be list bindings, because the list binding has no anchor element to track its insertion point among siblings. If you need dynamic columns, rebuild the entire grid container when the column set changes.
import { elements, tosi } from 'tosijs'
const columns = [
{ key: 'id', label: '#', w: '40px', sticky: 'left:0' },
{ key: 'name', label: 'Name', w: '120px', sticky: 'left:40px', edge: 'right' },
{ key: 'dept', label: 'Department', w: '110px' },
{ key: 'role', label: 'Role', w: '80px' },
{ key: 'level', label: 'Level', w: '60px' },
{ key: 'region', label: 'Region', w: '70px' },
{ key: 'joined', label: 'Joined', w: '90px' },
{ key: 'email', label: 'Email', w: '160px' },
{ key: 'team', label: 'Team', w: '100px' },
{ key: 'score', label: 'Score', w: '60px' },
{ key: 'status', label: 'Status', w: '80px', sticky: 'right:0', edge: 'left' },
]
const depts = ['Engineering', 'Design', 'Product', 'QA', 'Marketing', 'Sales']
const roles = ['Lead', 'Senior', 'Mid', 'Junior']
const regions = ['AMER', 'EMEA', 'APAC']
const teams = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Omega']
const statuses = ['Active', 'Away', 'Offline']
const data = Array.from({ length: 2000 }, (_, i) => ({
id: i,
name: `Person ${i}`,
dept: depts[i % depts.length],
role: roles[i % roles.length],
level: `L${3 + (i % 5)}`,
region: regions[i % regions.length],
joined: `2024-${String((i % 12) + 1).padStart(2, '0')}-15`,
email: `person${i}@example.com`,
team: teams[i % teams.length],
score: Math.floor(Math.random() * 100),
status: statuses[i % statuses.length],
}))
const { gridDemo } = tosi({ gridDemo: data })
const { div, span } = elements
const stickyStyle = (col) => col.sticky
? `position:sticky;${col.sticky}`
: ''
const cellClass = (base, col) =>
base + (col.sticky ? ' sg-pinned' : '') + (col.edge ? ` sg-edge-${col.edge}` : '')
// Headers and data cells are siblings in the same grid scroll container.
// Headers are static children; data cells come from the list binding.
// Both sticky columns (left/right) and the sticky header row work via CSS.
const grid = div(
{ class: 'sg-grid' },
// Header cells — static content, preserved across list updates
...columns.map(col =>
span({
class: cellClass('sg-header', col),
style: stickyStyle(col),
}, col.label)
),
// Data cells — itemsPerRow stamps one cell per column per array item
...gridDemo.tosi.listBinding(
({ span }, item, columnIndex) => {
const col = columns[columnIndex]
return span({
class: cellClass('sg-cell', col),
style: stickyStyle(col),
textContent: item[col.key],
})
},
{
idPath: 'id',
virtual: { height: 28, itemsPerRow: columns.length },
}
),
// Footer cells — static, pinned to bottom
...columns.map((col, i) =>
span({
class: cellClass('sg-footer', col),
style: stickyStyle(col),
}, i === 0 ? `${data.length}` : i === 1 ? 'rows' : '')
),
)
preview.append(grid)
.sg-grid {
height: 100%;
overflow: auto;
grid-template-columns: 40px 120px 110px 80px 60px 70px 90px 160px 100px 60px 80px !important;
}
.sg-header {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-color, #f5f5f5);
font-weight: bold;
padding: 4px 8px;
border-bottom: 2px solid #ccc;
white-space: nowrap;
}
.sg-cell {
padding: 4px 8px;
height: 28px;
line-height: 20px;
border-bottom: 1px solid #eee;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sg-pinned {
background: var(--bg-color, #fff);
z-index: 1;
}
.sg-footer {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--bg-color, #f5f5f5);
font-weight: bold;
padding: 4px 8px;
border-top: 2px solid #ccc;
white-space: nowrap;
}
.sg-header.sg-pinned, .sg-footer.sg-pinned {
z-index: 3;
background: var(--bg-color, #f5f5f5);
}
.sg-edge-right { border-right: 2px solid #ccc; }
.sg-edge-left { border-left: 2px solid #ccc; }
Variable-Height Items
If your list items have varying heights, add minHeight (the smallest an
item will be) to height (a typical item). Items then render at their
natural height.
Content moves exactly with the scroll, and rows that come and go off screen never move the rows you are looking at.
The scroll area is an estimate of the rows not built, using the average height of the rows measured so far (
heightuntil there are some). So the scrollbar thumb can change size a little as you scroll; the content does not jump.After a jump (dragging the thumb), rows are placed by that estimate, and scrolling back up absorbs the difference gradually, so the first item still arrives flush with the top.
minHeightdecides how many rows to build, so a smaller value builds more.An item that changes height (say, a row that expands) pushes the rows below it down; the rows above stay put.
The list sets
overflow-anchor: noneon its container, because the browser's own scroll anchoring would fight it....myArray.tosi.listBinding(({ div }, item) => div(item.name), { idPath: 'id', virtual: { height: 40, minHeight: 30, }, })
import { elements, tosi, scrollListItemIntoView } from 'tosijs'
import { tosiSegmented } from 'tosijs-ui'
// Heights vary a lot, and most items are well over minHeight: the hard case.
// Scroll slowly and watch an item; it should move exactly with your finger.
const sentence = 'Each item is as tall as its text, so heights vary a lot. '
const longList = Array.from({ length: 2000 }, (_, i) => ({
id: i,
label: `Item #${i}`,
description: sentence.repeat((i * 37) % 9),
}))
// The other edge: barely more than fits, and one item taller than the view.
const shortList = Array.from({ length: 6 }, (_, i) => ({
id: `short-${i}`,
label: `Short #${i}`,
description: sentence.repeat(i === 2 ? 60 : 1),
}))
const { varHeightExample } = tosi({ varHeightExample: { items: longList } })
const { div, span, button } = elements
const list = div(
{
class: 'var-height-list',
},
...varHeightExample.items.tosi.listBinding(
({div, span}, item) => div(
{
class: 'var-height-item',
},
span({ textContent: item.label, class: 'var-item-label' }),
span({ textContent: item.description, class: 'var-item-desc' }),
),
{
idPath: 'id',
virtual: {
height: 80,
minHeight: 30,
},
}
)
)
// one button each for the first, middle and last item of the current list
const lists = { long: longList, short: shortList }
const positions = [0, 0.5, 1]
const jumpButtons = positions.map((fraction) =>
button({
onClick() {
const items = varHeightExample.items.value
const index = Math.round(fraction * (items.length - 1))
scrollListItemIntoView(list, items[index], {
position: fraction === 1 ? 'end' : 'start',
})
},
})
)
const label = (items) => {
positions.forEach((fraction, i) => {
jumpButtons[i].textContent = `#${Math.round(fraction * (items.length - 1))}`
})
}
label(longList)
preview.append(
div(
{ class: 'var-height-demo' },
list,
div(
{ class: 'scroll-buttons' },
...jumpButtons,
tosiSegmented({
choices: 'long=2000 items, short=6 items',
value: 'long',
onChange(event) {
const items = lists[event.target.value]
varHeightExample.items.value = items
label(items)
},
}),
),
),
)
.var-height-demo {
display: flex;
flex-direction: column;
height: 100%;
}
.var-height-demo .scroll-buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
padding: 4px;
}
.var-height-list {
flex: 1 1 0;
min-height: 0;
width: 100%;
overflow-y: auto;
}
.var-height-item {
padding: 6px 10px;
border-bottom: 1px solid #eee;
display: flex;
flex-direction: column;
gap: 2px;
}
.var-height-item:nth-child(odd) {
background: #f8f8f8;
}
.var-item-label {
font-weight: bold;
font-size: 14px;
}
.var-item-desc {
font-size: 12px;
color: #666;
line-height: 1.4;
}
.var-item-desc:empty {
display: none;
}
Filtered Lists
It's also extremely common to want to filter a rendered list, and tosijs
provides both simple and powerful methods for doing this.
hiddenProp and visibleProp
hiddenProp and visibleProp allow you to use a property to hide or show array
elements (and they can be symbol values if you want to avoid "polluting"
your data, e.g. for round-tripping to a database.)
filter and needle
...filterListExample.array.tosi.listBinding(({ div }, item) => div(item.name), {
idPath: 'name',
virtual: {
height: 30,
},
filter: (emojis, needle) => {
needle = needle.trim().toLocaleLowerCase()
if (!needle) {
return emojis
}
return emojis.filter(emoji => `${emoji.name} ${emoji.category} ${emoji.subcategory}`.toLocaleLowerCase().includes(needle))
},
needle: filterListExample.needle
})
If the list binding's options provide a filter function and a needle (proxy or path) then
the list will be filtered using the function via throttled updates.
This is the way to filter or sort a bound list. The list stays bound to the array itself, so each row keeps its path: its bindings show the item, and edits in a row are written back to the item. The filter runs again when the array or the needle changes, at most ten times a second.
Do not bind a list to array.tosi.as(needle, fn) instead. .tosi.as()
transforms one value on its way to one element. It hands the list a new array
that has no path, so the rows cannot be bound to their items, and it is not
throttled. Use .tosi.as() for what is shown about the list (a count, a
disabled button) and for computing the needle.
filter is passed the whole array, and needle can be anything so, filter can
sort the array or even synthesize it entirely.
In this example the needle is an object containing both a needle string and sort
value, and the filter function filters the list if the string is non-empty, and
sorts the list if sort is not "default". Also note that an input event handler
is used to touch the object and trigger updates.
// note that this example is styled by the earlier example
import { elements, tosi } from 'tosijs'
const request = await fetch(
'https://raw.githubusercontent.com/tonioloewald/emoji-metadata/master/emoji-metadata.json'
)
const { filterListExample } = tosi({
filterListExample: {
config: {
needle: '',
sort: 'default',
},
array: await request.json()
}
})
const { b, div, span, template, label, input, select, option } = elements
preview.append(
div(
{
style: {
display: 'flex',
padding: 10,
gap: 10,
height: 60,
alignItems: 'center'
},
onInput() {
// need to trigger change if any prop of config changes
touch(filterListExample.config)
},
},
b('filtered list'),
span({style: 'flex: 1'}),
label(
span('sort by'),
select(
{
bindValue: filterListExample.config.sort
},
option('default'),
option('name'),
option('category')
),
),
input({
type: 'search',
placeholder: 'filter emoji',
bindValue: filterListExample.config.needle
})
),
div(
{
class: 'emoji-table',
style: 'height: calc(100% - 60px)',
},
...filterListExample.array.tosi.listBinding(
({div, span}, item) => div(
{
class: 'emoji-row',
tabindex: 0,
},
span({ textContent: item.chars, class: 'graphic' }),
span({ textContent: item.name, class: 'no-overflow' }),
span({ textContent: item.category, class: 'no-overflow' }),
span({ textContent: item.subcategory, class: 'no-overflow' })
),
{
idPath: 'name',
virtual: {
height: 30,
rowChunkSize: 3,
},
filter: (emojis, config) => {
let { needle, sort } = config
needle = needle.trim().toLocaleLowerCase()
if (needle) {
emojis = emojis.filter(emoji => `${emoji.name} ${emoji.category} ${emoji.subcategory}`.toLocaleLowerCase().includes(needle))
}
return config.sort === 'default' ? emojis : emojis.sort((a, b) => a[config.sort] > b[config.sort] ? 1 : -1)
},
needle: filterListExample.config
}
)
)
)
List Utilities
Suppose you have used a list binding to bind an array of objects
to a <ul>. So the DOM hierarchy looks something like this:
<ul> <-- array is bound to this element
<template>
<li>
<span>...</span>
</li>
</template>
<li> <-- bound to array[0]
<span>...</span>
</li>
<li> <-- bound to array[1]
<span>...</span>
</li>
...
</ul>
getListBinding(element: Element): ListBinding | undefined
This gets the ListBinding object managing the bound list contained on the provided
element (if any). In the example above, you could call it on the <ul> and you'd
get back a ListBinding instance that contains all kinds of juicy information.
getListItem(element: Element): any
Gets you the array item bound to the list instance containing the element (if any).
You could call this on an <li> element or a any element inside it and get back
the array item bound to the <li>.
getListInstance(element: Element): { element: Element, item: any } | undefined
This returns both the root element bound to the array item, and the array item itself.
Again, you could call this on an <li> or its contents.
deleteListItem(element: Element): boolean
If the element is part of a list instance bound to an array, this splices bound item out of the array (and updates the rendered list).
If you call this on an <li> or something inside it, this will splice the bound
array item out of the array and then triggers an update the bound list.
deleteListItem()requires that the list binding specifies a valididPath. Without one it logs an error and returnsfalse; it does not throw.
Removing a row: which function, and what it costs
There are three ways to remove a row, and they all do the same thing underneath: splice the item out of the array through the proxy.
| you have | use |
|---|---|
a DOM element in the row (a click handler's event.target) |
deleteListItem(element) |
| the item's id, or another field value | array.tosi.listRemove((item) => item.id, id) |
| the index | array.splice(index, 1) |
What it costs in the DOM, measured on a 200-row list with an idPath:
one row element is removed, no other row is created or moved, and each of the
199 rows still rendered has its bindings applied again. (Changing one field of
one item, by contrast, touches one element.) So removal is proportional to the
number of rows that exist, which is the practical reason to make a long list
virtual: only the rows on screen exist, however long the array is.
scrollListItemIntoView(element: Element, item: any, options?): boolean
Scrolls a bound array item into view within its list container. This is especially useful for virtualized lists where the item's DOM element may not exist yet.
element- the list container element (the one carrying the list binding)item- the raw array item to scroll tooptions.position- where to place the item in the viewport:'start','middle'(default),'end', or'nearest'options.behavior-'smooth'(default) or'instant'
Returns true on success, false if the binding or item can't be found
(with a console.error explaining what went wrong).
For virtual lists, the scroll position is computed mathematically from
the item's index and row height, then applied via scrollTo().
For non-virtual lists, the item's DOM element is found and
scrollIntoView() is called directly.
Future: a promise-based API using the
scrollendevent could notify callers when the scroll completes and the target item is rendered.
List Operations on Proxied Arrays
In addition to the utility functions above, proxied arrays have listFind,
listUpdate, and listRemove methods that use the same selector pattern
as listBinding. These are documented in detail under tosi, but
here's a quick summary:
// Find an item — returns a proxied item (mutations trigger observers)
const item = app.items.tosi.listFind((item) => item.id, 'abc')
// Find by DOM element (e.g. in a click handler)
const item = app.items.tosi.listFind(clickedElement)
// Upsert — update in place (preserving object identity) or push
app.items.tosi.listUpdate((item) => item.id, { id: 'abc', name: 'New Name' })
// Remove by field match
app.items.tosi.listRemove((item) => item.id, 'abc')
listUpdate is designed to work hand-in-hand with list bindings: it mutates
the existing object property by property through the proxy, so the
itemToElement WeakMap still points to the same DOM element. Only changed
properties fire observers, producing surgical DOM updates with no element
teardown or recreation.
Window Scroll Container (Experimental)
By default, virtualized lists scroll within their container element. For infinite-scroll
feeds or full-page lists, you can use the window as the scroll container instead by
setting scrollContainer: 'window' in the virtual options:
...feedItems.tosi.listBinding(({ div }, item) => div(item.title), {
idPath: 'id',
virtual: {
height: 120,
scrollContainer: 'window'
}
})
With scrollContainer: 'window', the list virtualizes based on the window's scroll
position and viewport height, rather than the element's own scroll position. The list
calculates which items are visible by comparing the element's position (via
getBoundingClientRect()) against the window's scroll position and inner height.
This is ideal for:
- Social media-style feeds
- Search results pages
- Any full-page scrolling list where the list is part of the main document flow
Important: The list element should not be inside a separately scrollable container when using window scroll. The element can be positioned anywhere on the page - the virtualization will correctly account for content above the list.
Templates and Namespaced Elements
List bindings use a template — the first child of the bound container — to stamp out repeated items. The template can be either:
- A
<template>element (recommended for HTML lists). Its content is inert and won't trigger premature bindings. - A naked element (the first child is used directly and removed from the DOM).
This is necessary for SVG and MathML containers, since
<template>is an HTML element and invalid inside namespaced contexts.
Relative bindings on templates
Bindings that start with ^ (relative to the list item) cannot resolve until the
template is cloned into a list instance. If touchElement encounters an unresolved
^ binding:
- HTML elements: a
console.warnis emitted suggesting you wrap the template in a<template>element (which keeps its content inert and avoids the issue). - Non-HTML elements (SVG, MathML, etc.): the binding is silently skipped, since
<template>is not available in these namespaces and naked templates are the only option.
In both cases, the ^ binding resolves correctly once the cloned element is placed
in a list instance.