Skip to content
Mercury
English
Design Book
Menu

Reflect.4. Preserving keyed rows

A row has an identity as well as a position. list lets a store update the collection while Hyperscript keeps each keyed row associated with its own local state.

Reflect 4.1.1 · Interactive exercise

Open the exercise on its own page

Separate identity from position

A checkbox held in a row's useState belongs to that mounted row. If the collection reverses, the checkbox should travel with its item, not stay in the first visual slot. Give list a stable getKey and compute the displayed position separately.

import {createElement as h} from "@fiberfx/hyperscript";
import {useState} from "@fiberfx/hyperscript/hooks";
import {event, store} from "@fiberfx/hypergraph";
import {Checkbox} from "@fiberfx/ui";
import {list} from "@fiberfx/reflect";

type Item = {id: string; title: string};
const removed = event<string>();
const reversed = event<void>();
const $items = store<Item[]>([
  {id: "a", title: "Alpha"}, {id: "b", title: "Beta"},
]).on(removed, (items, id) => items.filter(item => item.id !== id))
  .on(reversed, items => [...items].reverse());
const Row = (p: {item: Item; position: number; remove: () => void}) => {
  const [checked, setChecked] = useState(false);
  return h("li", null,
    h(Checkbox, {label: `${p.position}. ${p.item.title}`, checked, onChange: setChecked}),
    h("button", {onClick: p.remove}, "Remove"));
};
export const Rows = list({
  source: $items, view: Row, getKey: item => item.id,
  mapItem: {
    item: item => item,
    position: (_, index) => index + 1,
    remove: item => () => removed(item.id),
  },
});

Render h("ul", null, h(Rows, {})) under the application's Provider. The mapped remove function is scope-bound, so its event targets that collection's scope. The checkbox state belongs to the view in this demonstration; persistent business selection would usually belong in the model.

mapItem replaces the item's props. It does not merge {id, title} into the mapping above. Without mapItem, object items pass directly as row props. Shared bind values are defaults; mapped or direct row props override them.

Move a checked row

Open the keyed-list experiment. Check Alpha, then choose Reverse order. Reverse again, remove Beta, and inspect Alpha's checkmark and position after each action.

Choose Try a duplicate key. This lab validates the candidate collection before sending it to the rendered list and retains the last usable collection. Its message demonstrates an application guard; it is not a caught exception from the renderer.

Watch the same row survive

Alpha's local checkbox should remain checked through reorder, while its position label changes. Removing Beta should remove only Beta. Removing Alpha should end Alpha's row lifetime; restoring it later creates a new row with fresh local state.

In a regression test, retain Alpha's DOM node before reorder and compare it afterward as well as checking its state. Correct text alone would not reveal an unnecessary remount. Replacement item objects with the same key can update props without changing the row identity.

Reject a bad collection

The package itself rejects duplicate normalized keys, non-finite numeric keys, a source that is not a store, and state that is not an array. 1 and "1" normalize to the same key; 1 and "01" do not. Test those failures separately from the lab's application guard.

Without mapItem, primitive, null and array items are invalid. A mapping must supply every required prop not supplied by bind or mapProps; it cannot share a key with mapProps. A discriminated-union view needs complete object items, or a single wrapper prop such as item; independent field mappers cannot prove correlated union fields.

Restore the collection deliberately

Use Restart the whole exercise to restore the original rows and clear their local checkbox state. A model-only reset with the same mounted keys would not necessarily clear local view state. Decide which reset your product needs.

Repeat with Gamma checked instead of Alpha. Then remove the checked row and confirm that no other item inherits its checkmark.

Build an editable queue

Give each queue entry a stable domain identifier. Derive its visible index with mapItem; never use that index as identity when insertion or reorder is possible. With no getKey, list intentionally uses positions, which suits only genuinely positional collections.

Add an empty screen with variant, and keep loading/error states outside the row mapper. The next lesson makes these alternative screens explicit.

Previous · Course · Next