Reactive Collections

In React, updating a Map or Set requires cloning the entire collection on every mutation because the framework detects changes by reference. Effex takes a different approach: reactive collections allow in-place mutations that automatically notify observers.

// React (clone on every mutation)
setMap(new Map(map).set(key, value));
setSet(new Set(set).add(item));

// Effex (mutate in place, O(1))
yield* users.set(key, value);
yield* tags.add(item);

Signal.Array

A reactive array with familiar mutation methods:

const todos = yield* Signal.Array.make<Todo>([]);

// In-place mutations — no cloning
yield* todos.push({ id: 1, text: "Learn Effex", done: false });
yield* todos.unshift(firstItem);
yield* todos.pop();
yield* todos.shift();
yield* todos.splice(1, 2, replacement);
yield* todos.insertAt(0, item);
yield* todos.removeAt(index);
yield* todos.remove(specificItem);  // By reference
yield* todos.clear();

// Reordering
yield* todos.move(fromIndex, toIndex);  // Great for drag-and-drop
yield* todos.swap(indexA, indexB);
yield* todos.sort((a, b) => a.id - b.id);
yield* todos.reverse();

// Bulk operations
yield* todos.update((arr) => arr.filter((t) => !t.done));
yield* todos.set(newTodos);

// Reactive length
todos.length;  // Readable<number>

Rendering Lists

Signal.Array works directly with each for keyed list rendering:

each(todos, {
  key: (todo) => todo.id,
  render: (todo) => TodoItem(todo),
});

When you push, removeAt, or move items, only the affected DOM nodes are created, removed, or repositioned. The rest of the list is untouched.

Signal.Map

A reactive key-value store:

const users = yield* Signal.Map.make<string, User>();

// Mutations
yield* users.set("u1", { name: "Alice", role: "admin" });
yield* users.delete("u1");
yield* users.clear();
yield* users.replace(newMap);
yield* users.update((m) => new Map([...m, ["u2", bob]]));

Reading Values

Signal.Map provides two kinds of reads: reactive (for binding to UI) and one-time (for imperative code).

// Reactive reads — update the UI when the value changes
users.at("u1");              // Readable<Option<User>>
users.atOrElse("u1", guest); // Readable<User>
users.has("u1");             // Readable<boolean>

// One-time reads — for use in Effects
const user = yield* users.atEffect("u1");   // Effect<Option<User>>
const exists = yield* users.hasEffect("u1"); // Effect<boolean>

// Reactive derived values
users.size;         // Readable<number>
users.entries;      // Readable<readonly [string, User][]>
users.keys;         // Readable<readonly string[]>
users.valuesArray;  // Readable<readonly User[]>

Signal.Set

A reactive collection of unique values:

const tags = yield* Signal.Set.make<string>(["draft"]);

// Mutations
yield* tags.add("important");
yield* tags.delete("draft");
yield* tags.toggle("featured");  // Add if missing, remove if present
yield* tags.clear();
yield* tags.replace(newSet);
yield* tags.update((s) => new Set([...s, "extra"]));

// Reactive reads
tags.has("important");  // Readable<boolean>

// One-time read
const exists = yield* tags.hasEffect("important");  // Effect<boolean>

// Reactive derived values
tags.size;         // Readable<number>
tags.valuesArray;  // Readable<readonly string[]>

The toggle method is particularly useful for UI state like selected items, active filters, or feature flags.

Signal.Struct

A reactive object where each field is its own Signal. This gives you granular reactivity — updating one field doesn’t notify observers of other fields.

const address = yield* Signal.Struct.make({
  street: "123 Main St",
  city: "Austin",
  zip: "78701",
});

// Each field is a Signal — granular reads and writes
yield* address.street.set("456 Oak Ave");
yield* address.city.update((c) => c.toUpperCase());

// Read the whole struct as a single Readable
const value = yield* address.get;
// { street: "456 Oak Ave", city: "AUSTIN", zip: "78701" }

// Batch update multiple fields
yield* address.update({ street: "789 Pine Rd", city: "Houston" });

// Replace the entire struct
yield* address.replace({
  street: "100 New St",
  city: "San Antonio",
  zip: "78201",
});

// List of field keys
address.keys;  // readonly ["street", "city", "zip"]

When to Use Struct vs. Individual Signals

Use Signal.Struct when you have a group of related fields that are often read or updated together (like a form). Use individual Signals when the values are independent.

// Related fields — use Struct
const formData = yield* Signal.Struct.make({
  name: "",
  email: "",
  role: "user",
});

// Independent values — use individual Signals
const isOpen = yield* Signal.make(false);
const count = yield* Signal.make(0);