Mapped collections
Turn an array or record signal into a stable set of per-item derivations, so each item stays independently reactive across changes to the list.
@mmstack/primitivesnpm
The problem they solve
# You have a signal holding an array, and you want a derived value per item: a formatted label, a small view model, a per-row control. The obvious move is a computed that maps over the array.
That works, but it throws everything away and rebuilds it on every change. Change one item's name and the whole mapped array is recreated, which means fresh object references for every row. Anything downstream keyed on those references, a heavy component in a @for, a chart, a drag handle, sees every row as new and re-renders the lot. There is also no stable per-item signal to hand to a child, since each pass produces new objects.
These three helpers fix that. Each one maps a source array or record into a set of derived values that are created once per item and reused as the source changes. Change one item and only that item's derivation updates; the rest keep their identity. That gives you a stable per-item signal to pass down, and it lets change detection skip the rows that did not move.
indexArraystabilizes an array by position.mapArraystabilizes an array by identity.mapObjectis the record equivalent, stable by key.
They also pool their internal buffers, so reordering a large list is much cheaper than mapping a computed.
indexArray, the default
#indexArray is the one to reach for first. It maps the source into a stable array where each position gets its own writable child signal holding the item at that index. The mapping runs once per position and is reused as the array changes, so a growing list only creates entries for the new tail.
import { computed } from '@angular/core';
import { indexArray, mutable } from '@mmstack/primitives';
const items = mutable([
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
]);
// child is a MutableSignal<{ id, name }> for the current index
const labels = indexArray(items, (child, index) =>
computed(() => `Item ${index}: ${child().name}`),
);Because it is keyed by position, a mapped output follows the position, not the item. If the array reorders, position 0 keeps its derivation and its child signal simply reports the new item there. That is exactly what you want for a plain list of labels, and it is the cheaper of the two array helpers.
Solid users: mapArray is the keyed mapper here too; the slot mapper is indexArray.
mapArray, stable by identity
#mapArray stabilizes by identity instead. Items are their own keys, or you pass a key selector; moving an item then carries its derivation along, and only the entry's index signal updates. The mapping receives the item as a read-only signal, a Signal<number> for its current index, the raw key and the entry's handle. With a key, an immutable update (same id, new object) keeps the entry and the item signal reports the new object.
import { computed } from '@angular/core';
import { mapArray, mutable } from '@mmstack/primitives';
const items = mutable([
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
]);
// child is a Signal of the entry's item, index is a Signal<number>
const keyed = mapArray(
items,
(child, index) => computed(() => `${index()}: ${child().name}`),
{ key: (item) => item.id },
); The difference matters when an item owns something expensive that should survive a reorder: a rendered chart, a media element with playback state, a drag-and-drop row. With indexArray a reorder would hand each position a different item and force those to rebuild; with mapArray the instance moves with its item. If nothing downstream depends on instance reuse across reorders, indexArray is enough. Both accept an onDestroy callback that runs when a mapped entry is removed, so per-item resources can clean up.
A row is as fresh as the list's last read. The list writes each entry's item and index signals when it is read, so a row read before the list sees the previous state, never a mix of two: its item and index always belong to the same source array.
Duplicate keys
#Keys do not have to be unique. Every occurrence of a key gets its own entry, and an entry follows the occurrence it was built for, not its position among the duplicates. Which occurrence is which is decided by a few plain rules:
- The same reference keeps its entry. An item that is the same object before and after is that occurrence. With two objects of one key,
[a1, a2]becoming[a2]destroys the entry built froma1and re-creates nothing. - The same object listed several times only changes its count. Its occurrences cannot be told apart, so entries are created or destroyed for the difference and never swapped.
- Fresh objects are matched in order while their count stays the same. That is the immutable-update convention: replace every item with a new copy and each entry keeps its place.
- When the count of fresh objects changes, they are re-created. Nothing shows which one survived, so every entry they could belong to is destroyed and built again rather than guessed.
If your duplicates come from immutable updates that also add or remove copies, keep the references you did not change, and the first rule keeps their entries. For lists whose keys never repeat none of this comes into play: each key is one entry, matched by key alone.
Handles
# The mapping function receives the entry's raw key as its third argument and the entry's lifetime handle as its fourth: the raw key while that key never had duplicates, an opaque { key, n } object once it did. The handle stays the same for the life of the entry, even after the key becomes unique again, so it is a safe name for the entry. isKeyHandle(value) tells the two apart, and handle.key is the raw key.
import { mapArray } from '@mmstack/primitives';
const rows = mapArray(
items,
(item, index, id, handle) => ({
id, // the raw id
handle, // the raw id, or an opaque handle once the id was duplicated
item,
}),
// annotate the parameter: a mapping that names the key or handle is typed
// before the options, so an unannotated (item) => item.id is not inferred
{ key: (item: Item) => item.id },
); A handle is identified by its reference. One that crosses a structured-clone boundary, postMessage, a worker, or JSON, comes back as a plain object that isKeyHandle rejects and that matches no entry. Send the raw key across a boundary, never the handle.
To find out that a list has duplicates at all, pass onDuplicates. It is called once per recompute that sees any, with the repeated keys as their actual values in first-occurrence order. Nothing is coerced to a string, so 1 and "1" are different keys. It runs untracked, so it cannot add reactive dependencies.
const rows = mapArray(items, render, {
key: (item) => item.id,
onDuplicates: (ids) => console.warn('repeated ids', ids), // e.g. [3, 7]
});Relative order
# The result always has reorders, a read-only signal that says whether the entries changed their order relative to each other. It holds the permutation among the entries present both at its last evaluation and now, named by handle: before lists them in the old order, after the same handles in the new order. When they kept their relative order it is null.
const rows = mapArray(items, render, {
key: (item) => item.id,
});
rows.reorders(); // { before: [1, 2], after: [2, 1] } after a swap
// null after a prepend, an append or a removal Inserts and removals alone are not reorders. A prepend bumps every index signal and still leaves reorders() at null, so an animation or a sync layer can tell a real move from items shifting around an insert.
The baseline is the signal's own last evaluation, shared by everyone who reads it. Changes it did not see fold into one transition, so swapping two items and swapping them back before it is read gives null. Reading the mapped array in between loses nothing, since that read does not move the baseline. The first read is null. Unread, nothing is computed for it.
handles on its own
# The matching behind mapArray is exported as handles(prev, items, keyOf), for code that tracks the identity of list occurrences without mapping them. Pass the previous table (or null the first time), the new items and a key function. You get back the new table, whose handles line up with the items, and the handles that retired, in destroyed.
import { handles, type HandleTable } from '@mmstack/primitives';
let table: HandleTable<Item, number> | null = null;
function observe(items: Item[]) {
const next = handles(table, items, (item) => item.id);
for (const gone of next.destroyed) release(gone);
table = next.table; // table.handles[i] names items[i]
} Like pooled, the table and destroyed are reused by the next call, so the previous state stays readable for exactly one call; copy anything you keep longer. An unchanged input returns the previous table as it is.
mapObject for records
#mapObject is the record counterpart of mapArray. It maps a Record<K, V> into a Record<K, U> with referential stability for unchanged keys, and when the source is writable it hands the mapping a writable signal slice for each value, so each entry can both read and write its own value.
import { signal } from '@angular/core';
import { mapObject } from '@mmstack/primitives';
const settings = signal<Record<string, boolean>>({
wifi: true,
bluetooth: false,
});
const controls = mapObject(
settings,
(key, value) => ({
label: key.toUpperCase(),
isActive: value, // WritableSignal<boolean>
toggle: () => value.update((v) => !v),
}),
{ onDestroy: (entry) => console.log(`Removed ${entry.label}`) },
); The result is a set of self-contained controls, one per key, that stay the same objects until their key leaves the record. As with the array helpers, onDestroy runs when a key is removed.