<For> keeps stable rows by key. Its children callback runs when a row is
created or reconciled; it is not a general reactive scope. This distinction is
important when a row reads state owned by the parent component.
Keys passed to by must be stable, non-null, and unique within the list, and a
key must keep the same string or number type across renders. Development builds
validate this contract and throw a descriptive error for invalid, duplicate, or
type-changing keys. Production builds omit that validation pass and do not warn
or throw specifically for key violations so keyed reconciliation stays on its
lean path. Rendering with keys that violate the contract is unsupported and its
row identity is not guaranteed.
Use selector() for a keyed membership test. Only rows whose membership
changes need to update:
import { selector, state } from '@askrjs/askr';
import { For } from '@askrjs/askr/control';
const ITEMS = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
export function Navigation() {
const [selected, setSelected] = state('a');
const isSelected = selector(() => selected());
return (
<>
<button onClick={() => setSelected('c')}>Choose C</button>
<For each={ITEMS} by={(item) => item.id}>
{(item) => (
<a data-active={isSelected(item.id) ? 'true' : 'false'}>{item.id}</a>
)}
</For>
</>
);
}selector() is declared during component render and can be called from each
row. It tracks the source once and exposes a predicate for each item key.
When the row itself does not need to rerun, use a function-valued prop (a thunk). The renderer reevaluates the property when its reactive source changes:
<For each={ITEMS} by={(item) => item.id}>
{(item) => (
<li data-active={() => (selected() === item.id ? 'true' : 'false')}>
{item.id}
</li>
)}
</For>This looks natural but freezes the initial value for an existing row:
const current = selected();
<For each={ITEMS} by={(item) => item.id}>
{(item) => <li data-active={item.id === current ? 'true' : 'false'} />}
</For>;current is read while the parent renders, but the row callback does not
subscribe to that read. Replace it with selector() or a thunk prop.
The same rule applies to any parent state(), derive(), or reactive getter
captured by a row callback. Keys still need to be stable; use byIndex only
when positional identity is intentional.
This repository's Vitest/jsdom test harness can mount this component. Keep the state update and the row assertion in the same test so a stale closure cannot pass unnoticed:
const active = () =>
[...document.querySelectorAll('[data-active="true"]')].map(
(node) => node.textContent
);
expect(active()).toEqual(['a']);
document.querySelector('button')?.dispatchEvent(new MouseEvent('click'));
await waitForNextEvaluation();
expect(active()).toEqual(['c']);
waitForNextEvaluation() is provided by the repository test setup; it is not
part of the published @askrjs/askr package.
<For>, <Show>, and the other eager control primitives retain
render-scoped state. Do not make the primitive call itself appear or disappear
behind a plain if, ternary, && branch, or changing loop:
function Rows() {
// Avoid: the For call is skipped while open() is false.
return (
<div>
{open() ? (
<For each={items} by={(item) => item.id}>
{(item) => <Row item={item} />}
</For>
) : null}
</div>
);
}Keep the outer control boundary unconditional and put the conditional branch
inside <Show>, or use a <Case> boundary with <Match> children:
<Show when={open}>
{() => (
<For each={items} by={(item) => item.id}>
{(item) => <Row item={item} />}
</For>
)}
</Show>This rule is about primitives evaluated in the current component's render
scope. A normal JSX child such as <Dialog /> is reconciled as its own
component instance; its internal hooks do not become conditional hooks in the
parent merely because the parent selected that child with ordinary JavaScript.