Selection in constant time
A highlighted row is the classic list update that scales badly: class={{ selected: selected() === row().id }} makes every row read selected(), so one click re-runs…
A highlighted row is the classic list update that scales badly: class={{ selected: selected() === row().id }} makes every row read selected(), so one click re-runs one binding per row. useSelector(source, equals?) returns isSelected(key). A binding that calls isSelected(id) subscribes only to that key's boolean. When the source changes from a to b, only the bindings for a and b re-run.
src/table.tsxtsx
import { useSelector, useSignal, useStore } from "@shadow-js/core";
import { For } from "@shadow-js/web";
export const reads = { selector: 0, naive: 0 };
interface Row {
id: number;
label: string;
}
export function Table(props: { size?: number }) {
const size = props.size ?? 3;
// useSignal: replaced as a whole on purpose (a store would reconcile it).
const [rows] = useSignal<Row[]>(Array.from({ length: size }, (_, id) => ({ id, label: `row ${id}` })));
const [selected, setSelected] = useStore<number | null>(null);
const isSelected = useSelector(selected);
return (
<div>
<For each={rows()} key={(row) => row.id}>
{(row) => (
<p
class={{ picked: (reads.selector += 1, isSelected(row().id)) }}
data-naive={(reads.naive += 1, selected() === row().id)}
onClick={() => setSelected(row().id)}
>
{row().label}
</p>
)}
</For>
</div>
);
}
Details of useSelector:
- Create the selector before the rows that consume it, so each consumer runs once per change.
- It tracks
sourcewith a render-phase effect in the current owner. Called outside a tracked context,isSelected(key)reads the source directly and creates no subscription. - The default comparison is
===(soNaNnever matches). Passequals(key, value)for other relations, such as "row id is in the selected set": that costs oneequalscall per registered key per change instead of two lookups.