Skip to content

Reactive Collections ​

When an array, Map, or Set is stored on a reactive object, Udodi wraps it so structural mutations notify the owning property.

Deep changes inside elements or values are not tracked automatically. Use touch() or replace the element/property when those need to notify.

How Wrapping Works ​

Assignment (or initialization) through a reactive object runs the value through a collection check:

import { reactive, effect } from "udodi";

const state = reactive({
  items: [],
  tags: new Set(),
  meta: new Map(),
});

effect(() => {
  console.log(
    state.items.length,
    state.tags.size,
    state.meta.size,
  );
});

state.items.push({ id: 1 }); // notifies "items"
state.tags.add("ui");        // notifies "tags"
state.meta.set("v", 1);      // notifies "meta"

The wrapper:

  1. Intercepts known mutation methods
  2. Tracks direct array index and length assignments
  3. Applies mutations to the underlying collection
  4. Calls touch(owner, key) so dependents of that reactive property re-run

Wrapped collections are marked with __udodi_reactive__ so they are not wrapped again.

For Map and Set, native methods and accessors are evaluated against the underlying collection so operations such as size, get(), has(), entries(), and forEach() work correctly through the reactive wrapper.

Structural vs Deep ​

ChangeNotifies owner?
items.push(x) / pop / splice / ...Yes
items[0] = xYes
items.length = 0Yes
tags.add(x) / delete / clearYes
meta.set(k, v) / delete / clearYes
items = nextArray (replace property)Yes
items[0].name = "..."No
meta.get(k).field = "..."No

For deep updates:

state.items[0].name = "updated";
touch(state, "items");

// or replace the element / whole collection
state.items = state.items.map((item, i) =>
  i === 0 ? { ...item, name: "updated" } : item,
);

Direct array index and length assignments do not require touch():

state.items[0] = nextItem;
state.items.length = 0;

Arrays ​

Notifying methods ​

MethodNotifies
pushYes
popYes
shiftYes
unshiftYes
spliceYes
sortYes
reverseYes
fillYes
copyWithinYes

Direct index and length assignments ​

Direct writes to array indexes are reactive:

state.items[0] = nextItem;

Assigning length is also reactive:

state.items.length = 0;

Both operations notify dependents of the owning root state key automatically. No explicit touch() is needed.

This makes direct indexed updates suitable for replacing existing items without allocating a new array:

for (let i = 0; i < state.items.length; i += 10) {
  const row = state.items[i];

  state.items[i] = {
    id: row.id,
    label: row.label + " !!!",
  };
}

Clearing an array can likewise use:

state.items.length = 0;

Example ​

const state = reactive({ todos: [] });

effect(() => {
  console.log("count", state.todos.length);
});

state.todos.push({ title: "Write docs", done: false });
state.todos.push({ title: "Ship", done: false });

state.todos[0] = {
  ...state.todos[0],
  done: true,
};

state.todos.length = 0;

Array contents are not deeply reactive ​

Objects stored inside an array are not recursively proxied:

state() {
  return {
    users: [
      { name: "Ada" },
    ],
  };
},

This does not automatically notify dependents:

this.users[0].name = "Grace";

Notify the root key explicitly:

this.users[0].name = "Grace";
touch(this, "users");

Or replace the item through a reactive array mutation:

this.users.splice(0, 1, {
  ...this.users[0],
  name: "Grace",
});

The second approach automatically notifies because splice() is a reactive array mutation.

When the array is replaced ​

state.todos = state.todos.filter((t) => !t.done);

Replacing the property notifies through the normal reactive setter. The new array is wrapped again if needed.

Maps ​

Notifying methods ​

MethodNotifies
setYes
deleteYes
clearYes
const state = reactive({
  scores: new Map(),
});

effect(() => {
  console.log(state.scores.get("Ada"));
});

state.scores.set("Ada", 10);
state.scores.set("Ada", 11);
state.scores.delete("Ada");

Map accessors and non-mutating methods operate against the underlying Map, so they can be used normally through the reactive wrapper:

state.scores.size;
state.scores.get("Ada");
state.scores.has("Ada");

for (const [name, score] of state.scores.entries()) {
  console.log(name, score);
}

Deep values ​

state.scores.set("Ada", { points: 10 });

// mutating the object inside the Map does not notify
state.scores.get("Ada").points = 11;
touch(state, "scores");

// or set a new value
state.scores.set("Ada", { points: 11 });

Sets ​

Notifying methods ​

MethodNotifies
addYes
deleteYes
clearYes
const state = reactive({
  selected: new Set(),
});

effect(() => {
  console.log([...state.selected]);
});

state.selected.add("a");
state.selected.add("b");
state.selected.delete("a");
state.selected.clear();

Set accessors and non-mutating methods operate against the underlying Set:

state.selected.size;
state.selected.has("a");

for (const value of state.selected.values()) {
  console.log(value);
}

Objects in Sets ​

Identity is by reference. Mutating an object that is already in the Set does not change Set membership and does not notify:

const user = { id: 1, name: "Ada" };
state.selected.add(user);

user.name = "Grace";
touch(state, "selected"); // if dependents need to re-run

Ownership ​

The wrapper notifies the reactive property that owns the collection (owner + key captured at wrap time):

state.items  →  reactiveArray(array, state, "items")
                     │
                     └── push/index/length  →  touch(state, "items")

Always mutate through the reference held on the reactive object (or a reference obtained from it). A bare array that was never assigned to reactive state is not wrapped.

const orphan = [];
orphan.push(1); // not reactive

state.items = orphan; // now wrapped; future structural mutations notify
state.items.push(2);  // notifies
state.items[0] = 3;   // notifies
state.items.length = 0; // notifies

Replacing vs Mutating ​

ApproachExampleNotifies
Structural mutationstate.items.push(x)Yes (wrapper)
Property replacestate.items = nextYes (setter)
Array index assignmentstate.items[i] = yYes (wrapper)
Array length assignmentstate.items.length = 0Yes (wrapper)
Deep field changestate.items[0].x = yOnly with touch or replace

Immutable-style updates are always safe and readable:

state.items = [...state.items, newItem];
state.meta = new Map(state.meta).set(key, value);
state.tags = new Set(state.tags).add(tag);

In-place mutation is fine when you want to avoid copying. Rely on the wrapper for structural methods, direct array index and length assignments, and touch() for deep edits.

Interaction With Effects and Computed ​

const state = reactive({ items: [] });

const total = computed(() =>
  state.items.reduce((sum, row) => sum + row.amount, 0),
);

effect(() => {
  console.log("total", total());
});

state.items.push({ amount: 10 }); // structural → notifies → total recomputes
state.items[0] = { amount: 20 };  // index write → notifies → total recomputes

If you only change row.amount in place, call touch(state, "items") (or replace the row) so total and the effect update.

API Summary ​

Collections are not constructed with a public reactiveArray() API in application code. They are applied automatically when values are set on reactive state.

CollectionStructural operations that notify
Arraypush, pop, shift, unshift, splice, sort, reverse, fill, copyWithin, index assignment length assignment
Mapset, delete, clear
Setadd, delete, clear
RelatedRole
reactive({ ... })Host object; assignment wraps collections
touch(proxy, key)Notify after deep mutations
__udodi_reactive__Internal mark; do not rely on it in app code

Constraints ​

BehaviorDetail
Structural onlyMethod wrappers cover listed mutations
Array index assignmentDirect index writes notify automatically
Array length assignmentDirect length writes notify automatically
No deep trackingElement/value field changes need touch or replacement
One owner keyNotifications go to the property that held the collection at wrap time
No double wrapAlready marked collections are left as-is