Reactivity
What You're Used To
In React, changing state re-runs your entire component function:
typescript// React — the whole function re-executes
function Counter() {
const [count, setCount] = useState(0); // runs every render
const doubled = count * 2; // recalculated every render
return <h1>{count} × 2 = {doubled}</h1>; // diffed every render
}Every setCount call re-runs Counter(), recalculates doubled, builds a new virtual DOM tree, diffs it against the old one, and patches the real DOM. For a single number change, the framework does O(n) work where n is the component tree size.
In Svelte, the compiler rewrites your code to track which variables are reactive and generates update blocks per component. Better, but still component-scoped — one variable change re-runs the component's update logic.
How CellUI Works
CellUI components execute once. Signals bind directly to DOM nodes. When a value changes, exactly one text node or attribute updates:
typescriptimport { signal, view } from '@cmj/cellui';
function Counter() {
const count = signal(0);
return view`
<button onclick="${() => count.value++}">
Clicked ${count} times
</button>
`;
}This function runs once. count is not re-declared. The view template is not re-evaluated. When count.value++ happens, CellUI updates the single text node between "Clicked " and " times". Nothing else executes.
This is O(1) — constant time regardless of how many other signals, components, or DOM nodes exist on the page.
signal(value)
A signal wraps a value and maintains a subscriber list:
typescriptconst count = signal(0);
count.value; // read: returns 0
count.value = 5; // write: notifies all subscribers, updates bound DOM nodes
count.value = 5; // no-op: same value, no notification (strict equality check)Signals are class instances, not functions. You read and write through .value. This is different from Solid's createSignal (which returns a getter/setter pair) and from React's useState (which returns a value/setter pair).
subscribe() and track()
typescriptconst name = signal('Alice');
// subscribe: called immediately with current value, then on every change
const unsub = name.subscribe(v => console.log('name is', v));
// logs: "name is Alice"
name.value = 'Bob';
// logs: "name is Bob"
unsub(); // stop listening
name.value = 'Charlie'; // nothing loggedtrack() is the same as subscribe() but without the immediate initial call. It's used internally by effect().
computed(fn)
Derived values that auto-track their dependencies:
typescriptimport { signal, computed } from '@cmj/cellui';
const price = signal(10);
const quantity = signal(3);
const total = computed(() => price.value * quantity.value);
total.value; // 30
price.value = 20;
total.value; // 60 — recomputed automaticallycomputed() is syntactic sugar over signal() + effect(). It creates a signal whose value is kept in sync with its dependencies. No dependency array — CellUI tracks which signals you read inside the function.
When to use computed vs effect
- computed: when you need a value that other things can read. Returns a signal.
- effect: when you need a side effect (logging, fetching, DOM mutation). Returns nothing.
typescript// computed: "what is the total?"
const total = computed(() => price.value * qty.value);
// effect: "log every time total changes"
effect(() => console.log('Total:', total.value));effect(fn)
Run code whenever dependencies change. No dependency array:
typescriptimport { signal, effect } from '@cmj/cellui';
const query = signal('');
effect(() => {
// CellUI auto-tracks: this effect depends on `query`
console.log('Searching:', query.value);
});
query.value = 'cellui'; // logs "Searching: cellui"How auto-tracking works
When effect(fn) runs, it sets a global activeEffect pointer, then calls fn(). Every signal whose .value is read during fn() adds activeEffect to its subscriber list. When fn() finishes, activeEffect is cleared.
This means:
- Only signals you actually read become dependencies
- Conditional reads create conditional dependencies
- You never manually declare what to watch
When NOT to use effect
Don't use effects to derive state. Use computed() instead:
typescript// BAD: imperative state derivation
const total = signal(0);
effect(() => { total.value = price.value * qty.value; });
// GOOD: declarative
const total = computed(() => price.value * qty.value);Don't use effects for one-time setup. The effect will re-run if any signal it reads changes:
typescript// BAD: runs every time `config` changes
effect(() => {
initLibrary(config.value); // called repeatedly!
});
// GOOD: read without tracking
import { untracked } from '@cmj/cellui';
const cfg = untracked(() => config.value);
initLibrary(cfg);Deep Signals
When you pass an object to signal(), CellUI wraps it in a Proxy. Accessing nested properties returns child signals:
typescriptconst user = signal({
name: 'Alice',
age: 25,
settings: { theme: 'dark' }
});
// Each property access returns a Signal, not a raw value
user.name // Signal<string>
user.name.value // 'Alice'
user.settings.theme // Signal<string>
user.settings.theme.value // 'dark'Mutating deep properties
Change nested values through the proxy chain:
typescriptuser.name.value = 'Bob'; // ✓ updates the signal + syncs parent
user.settings.theme.value = 'light'; // ✓ updates theme signal
// In templates, bind directly:
view`<span>${user.name}</span>` // ✓ updates when name changesImportant: parent sync is one level
When you change a child signal, it syncs to its immediate parent but not further up the chain. For objects nested more than one level deep:
typescriptconst app = signal({ ui: { sidebar: { open: true } } });
app.ui.sidebar.open.value = false;
app.ui.sidebar.value.open; // false ✓ (parent synced)
app.ui.value.sidebar.open; // true ✗ (grandparent NOT synced)This is a known limitation of the Proxy-based design. In practice it doesn't matter because you should always read through the proxy chain (app.ui.sidebar.open.value), never through intermediate .value calls.
When NOT to use deep signals
Deep signals add Proxy overhead. For hot paths or large objects:
typescript// BAD: wrapping a 10,000-item array in a deep signal
const items = signal(hugeArray); // Proxy on every access
// GOOD: use a flat signal for the array, deep signals for each item's state
const items = signal(hugeArray); // no deep property access needed
const selectedId = signal(null); // separate signal for UI stateMemory Safety (WeakRef)
When CellUI binds a signal to a DOM node via view, it stores the binding using WeakRef:
typescript// Internal: signal.bindNode(textNode, updater)
// The textNode is held via WeakRef, not a strong referenceWhen the DOM node is removed (e.g., by when() or each() swapping content), the garbage collector can collect it. On the next signal update, CellUI checks if the WeakRef is still alive — if not, it silently drops the subscription.
This means:
- No manual cleanup for signal→DOM bindings
- No "zombie subscriptions" leaking memory
- No
useEffectcleanup function needed for DOM bindings
This is different from React (requires cleanup returns in useEffect), Solid (requires onCleanup), and Vue (handled by the compiler but opaque).
What CellUI Is Not
CellUI signals are not a state management library. There's no middleware, no time-travel debugging, no Redux-style actions/reducers. If you need those patterns, use signals as the reactive layer and build your own store on top — or don't. Many apps don't need global state management.
CellUI is not a compiler. Your code runs as-is. There's no compile-time transformation like Svelte. The Vite plugin optimizes templates but is optional. This means CellUI's behavior is always predictable — what you write is what executes.