Arrays & Collections
Reactive arrays and objects - copy-on-write, manual dispatch, child States, has() and map()
Reactivity in Expressive is keyed to assignment. this.items = [...] dispatches an update; this.items.push(item) does not - the property still holds the same array, so as far as === change detection is concerned, nothing happened.
That's the trade for cheap, predictable updates, and there are a few good ways to work with it. Pick by how granular you need to be.
Copy-on-write
The zero-machinery answer. Replace the collection instead of mutating it:
class TodoList extends State {
items: string[] = [];
add(text: string) {
this.items = [...this.items, text];
}
remove(index: number) {
this.items = this.items.filter((_, i) => i !== index);
}
}Every subscriber of items updates, once, at the next flush. For most lists this is plenty - the spread is cheaper than it looks, and there's nothing new to learn.
Announcing a mutation
If you'd rather mutate in place, tell the state about it yourself. set(key) dispatches an update event for a property without changing its value:
class TodoList extends State {
items: string[] = [];
add(text: string) {
this.items.push(text);
this.set('items');
}
}Anything subscribed to items re-runs. Granularity is still the whole property - subscribers can't tell what changed, only that something did.
Child States
When entries have identity and behavior of their own, make them States:
class Item extends State {
text = '';
done = false;
toggle() {
this.done = !this.done;
}
}
class TodoList extends State {
items = [Item.new({ text: 'Learn Expressive' })];
add(text: string) {
this.items = [...this.items, Item.new({ text })];
}
}The list owns membership (copy-on-write for add/remove); each item owns its fields. A row component subscribing to one Item re-renders when that item changes, and the list doesn't. Try it live.
Adding and removing still goes through reassignment here - the array itself is ordinary. The per-item reactivity comes from each entry being a State.
has()
For reactivity inside the collection - this index changed, that member joined - declare the collection with has():
import State, { has } from '@expressive/react';
class Game extends State {
board = has<string>(Array(9).fill(''));
play(index: number) {
this.board.set(index, 'X');
}
}has() is a field instruction: it resolves when the hosting state activates, which adopts the collection in the same step. The field is read-only - there is no whole-collection reassignment to lose reactivity to, and no separate value sharing storage with it.
The argument picks the mode. Nothing or an iterable makes an ordered list of values, addressed by index. A State class or a factory makes a pool of members it spawns and owns.
Lists
Lists are positional: get(index) (negative counts from the end), get(start, end) for a range, get(predicate) for the first match, set(index, value) to replace, put(index, ...values) to insert, push to append, pop(index?, count?) to remove.
Reads track precisely - get(index) subscribes to that index alone, size and iteration subscribe to length:
const game = Game.new();
game.get(($) => {
console.log($.board.get(0));
});
game.board.set(0, 'X'); // reruns the effect
game.board.set(5, 'O'); // does not - the effect only read index 0Replacing one index notifies that index. Inserting or removing mid-list notifies every shifted position plus length.
Pools
When members are owned States, hand has() the class (or a factory) instead of values. There is no positional surface - add(...args) spawns a member and returns it, and the member itself is the identity for has, delete, and eviction:
class TodoList extends State {
todos = has(Item);
protected new() {
this.todos.add({ text: 'Learn Expressive' });
}
clearDone() {
for (const item of [...this.todos])
if (item.done) this.todos.delete(item);
}
}add forwards its arguments exactly as Item.new() accepts them. Pools have no initial argument - they spawn - so seed them from the new() hook, which runs once the field has resolved.
Ownership follows freshness: a member the pool instantiates or a factory constructs fresh (new Item()) is owned, and is destroyed when deleted, cleared, or when the owner dies. An already-activated value (Item.new()) passed through a factory is a guest - held, never destroyed. Either way, a member that dies evicts itself, so a pool never serves a destroyed State.
Both modes share a read surface built over iteration - map(fn), filter(fn), any(fn), all(fn), get(predicate):
class TodoList extends State {
todos = has(Item);
get remaining() {
return this.todos.filter((item) => !item.done).length;
}
}In
@expressive/reacta collection of Components renders directly -<ul>{this.todos}</ul>- no spread and no keys. See Components.
map()
When entries are addressed by name rather than position, use map():
import State, { map } from '@expressive/react';
class Form extends State {
values = map<string, string>();
}
const form = Form.new();
form.get(($) => {
console.log($.values.get('email'));
});
form.values.set('email', '[email protected]'); // reruns the effect
form.values.set('name', 'Ada'); // does not - the effect only read emailget(key) and has(key) track that key only. size, iteration, keys(), values(), entries(), and forEach() track collection shape; the value-bearing ones also track the values they visit.
Given a factory, a map spawns and owns its values instead, keyed by the factory's first argument:
class Room extends State {
people = map((id: string) => new Person({ id }));
join(id: string) {
this.people.set(id);
return this.people.get(id)!;
}
}Ownership works exactly as it does for a pool - a fresh State is adopted and destroyed on delete, clear, or replacement; an already-activated one stays a guest.
Snapshots
Both instructions expose a get() seam with no arguments, and State snapshots use it - the same way they do for ref and child states. A list snapshots to a plain array, a map to a ReadonlyMap, with nested values exported through their own get():
class Model extends State {
list = has([1, 2, 3]);
keyed = map([['a', 1]]);
}
const snapshot = Model.new().get();
snapshot.list; // [1, 2, 3]
snapshot.keyed.get('a'); // 1Shallow only
Neither instruction wraps nested plain arrays or objects:
class Model extends State {
keyed = map<string, { value: number }>();
}
model.keyed.get('a')!.value = 2; // not reactive by itselfNesting is what States are for - a child State, has(), or map() value keeps its own reactivity when reached through the collection:
class Cell extends State {
value = '';
}
class Game extends State {
cells = has(Cell);
rows = map((id: string) => new Row(id));
}In action
Tic-tac-toe is a list's home turf - a fixed board where each cell is one index, and each move should repaint one square:
Note the computed winner getter reading board through tracking - it recomputes only when the cells it checked actually change. Not bad for one collection. ✨
Next
- Reactivity - how tracking and batching work underneath.
- Owned collections example - a pool of Component members, live.
- API: Instructions -
set,get,ref,def, and the fullhas/mapsurface.