Advanced Reactivity
SignalX provides advanced utilities for fine-grained control over reactivity. These are escape hatches for performance optimization and edge cases.
Batching Updates
Use batch() to group multiple signal updates into a single reactive flush:
import { signal, batch, effect } from 'sigx';
const state = signal({
firstName: 'John',
lastName: 'Doe',
age: 30
});
// Effect will log on each update
effect(() => {
console.log(`Name: ${state.firstName} ${state.lastName}, Age: ${state.age}`);
});
console.log('--- Without batch (effect runs 3 times) ---');
state.firstName = 'Jane';
state.lastName = 'Smith';
state.age = 25;
console.log('--- With batch (effect runs once) ---');
batch(() => {
state.firstName = 'John';
state.lastName = 'Doe';
state.age = 30;
});
When to Use Batch
- Multiple related updates - Updating several properties at once
- Loop updates - Modifying signals in a loop
- Performance - Reducing effect re-runs
// Efficient bulk update
batch(() => {
items.forEach((item, i) => {
state.items[i] = processItem(item);
});
state.lastUpdated = Date.now();
});
Nested Batching
Batches can be nested - effects only run after the outermost batch completes:
import { signal, batch, effect } from 'sigx';
const state = signal({ a: 0, b: 0, c: 0, d: 0 });
effect(() => {
console.log(`State: a=${state.a}, b=${state.b}, c=${state.c}, d=${state.d}`);
});
console.log('--- Starting nested batch ---');
batch(() => {
state.a = 1;
console.log('Set a=1 (inside outer batch)');
batch(() => {
state.b = 2;
state.c = 3;
console.log('Set b=2, c=3 (inside inner batch)');
});
console.log('Inner batch complete, effect still pending...');
state.d = 4;
console.log('Set d=4 (still inside outer batch)');
});
console.log('Outer batch complete - effect ran once!');
Enumerating Reactive Objects
Enumerating a reactive object inside an effect or a computed subscribes to its
key set, so a key appearing or disappearing re-runs the reader. That covers
Object.keys(), for…in, object spread and rest destructuring:
import { signal, effect } from 'sigx';
const state = signal<Record<string, number>>({ a: 1 });
effect(() => {
console.log(Object.keys(state).join(', '));
});
state.b = 2; // re-runs — the key set changed
delete state.a; // re-runs — so does removal
state.b = 99; // no re-run — Object.keys reads no values
The key-set subscription is on top of the per-key ones, which is what makes the two enumeration styles differ:
Object.keys()reads no values, so it depends on the key set alone. Changingstate.afrom1to2doesn't re-run it.- A spread copies every value, so it depends on the key set and every key — it re-runs when any of them changes.
'x' in state subscribes to the same per-key dependency a read of state.x
would, so presence and value always agree.
Arrays carry their enumeration dependency on length rather than a separate
one, and every change to an array's key set is routed there — including the two
cases where the key set moves but the length doesn't, delete list[i] and
filling the hole it leaves. Maps and Sets have always tracked their own
iteration and .size.
Plain property access is unaffected. Reading state.foo goes through the proxy
as it always has, but it never reaches the enumeration trap, so it creates no
key-set dependency and the common path costs nothing extra.
Bulk Key Insertion
Because each new key re-runs every reader that enumerates the object, adding
keys one at a time is quadratic. Wrap the loop in batch():
// One re-run, not one per key.
batch(() => {
for (const [key, value] of entries) state[key] = value;
});
This is worth knowing about any object something enumerates for a side effect — it now runs more often than a reader that only touched individual keys.
Reading Without Tracking
Use untrack() to read signal values without creating a dependency:
import { signal, effect, untrack } from 'sigx';
const state = signal({ count: 1, multiplier: 2 });
effect(() => {
// This effect depends on count but NOT multiplier
const mult = untrack(() => state.multiplier);
console.log(`count(${state.count}) * multiplier(${mult}) = ${state.count * mult}`);
});
console.log('--- Incrementing count (effect WILL run) ---');
state.count++;
console.log('--- Incrementing multiplier (effect will NOT run) ---');
state.multiplier++;
console.log('Multiplier changed but effect did not re-run');
Use Cases
-
One-time reads - Read initial/default values:
TSXconst initialValue = untrack(() => config.defaultValue); -
Avoid circular dependencies:
TSXeffect(() => { const current = state.value; // Use untrack to avoid triggering this effect again untrack(() => history.push(current)); }); -
Conditional dependencies:
TSXeffect(() => { if (state.useAdvanced) { // Only track advancedData when useAdvanced is true console.log(state.advancedData); } else { // Don't track advancedData when not needed untrack(() => console.log(state.advancedData)); } });
Getting Raw Objects
Use toRaw() to get the underlying non-reactive object from a signal:
import { signal, toRaw, isReactive } from 'sigx';
const state = signal({
users: ['Alice', 'Bob'],
settings: { theme: 'dark' }
});
console.log('state is reactive:', isReactive(state));
// Get the raw, non-reactive object
const raw = toRaw(state);
console.log('raw is reactive:', isReactive(raw));
console.log('Raw object:', JSON.stringify(raw, null, 2));
Nested Objects
toRaw() only unwraps the immediate proxy. For nested objects:
const state = signal({
user: {
profile: { name: 'John' }
}
});
// Only unwraps outer proxy
const raw = toRaw(state);
// raw.user is still reactive if accessed through the original proxy
// For deep unwrapping, use structuredClone or manual deep copy
const deepRaw = JSON.parse(JSON.stringify(toRaw(state)));
Checking Reactivity
Use isReactive() to check if a value is a reactive proxy:
import { signal, isReactive, toRaw } from 'sigx';
const state = signal({ count: 0 });
const plain = { count: 0 };
console.log('state is reactive:', isReactive(state));
console.log('plain is reactive:', isReactive(plain));
// Useful for debugging or conditional logic
function logValue(value: any) {
if (isReactive(value)) {
console.log('Reactive:', JSON.stringify(toRaw(value)));
} else {
console.log('Plain:', JSON.stringify(value));
}
}
logValue(state);
logValue(plain);
Effect Scopes
Use effectScope() to group effects and stop them together:
import { signal, effect, effectScope } from 'sigx';
const scope = effectScope();
const state = signal({ count: 0 });
scope.run(() => {
// These effects are tracked by the scope
effect(() => console.log('Count:', state.count));
effect(() => console.log('Double:', state.count * 2));
});
console.log('--- Updating count (effects run) ---');
state.count = 5;
console.log('--- Stopping scope ---');
scope.stop();
console.log('--- Updating count again (effects do NOT run) ---');
state.count = 10;
console.log('Count is now', state.count, 'but effects were stopped');
Detached Scopes
Create a detached scope that doesn't stop when its parent stops:
import { signal, effect, effectScope } from 'sigx';
const state = signal({ value: 0 });
const parentScope = effectScope();
let detachedScope: ReturnType<typeof effectScope>;
parentScope.run(() => {
// Detached scope - independent lifetime
detachedScope = effectScope(true);
effect(() => console.log('Parent effect - value:', state.value));
detachedScope.run(() => {
// This effect survives parent.stop()
effect(() => console.log('Detached effect - value:', state.value));
});
});
console.log('--- Updating value (both effects run) ---');
state.value = 1;
console.log('--- Stopping parent scope ---');
parentScope.stop();
console.log('--- Updating value (only detached runs) ---');
state.value = 2;
Use Cases
-
Component cleanup - Group all component effects:
TSXconst MyComponent = component((ctx) => { const scope = effectScope(); ctx.onMounted(() => { scope.run(() => { effect(() => { /* ... */ }); effect(() => { /* ... */ }); }); }); ctx.onUnmounted(() => { scope.stop(); }); }); -
Feature modules - Enable/disable features:
TSXlet featureScope: EffectScope | null = null; function enableFeature() { featureScope = effectScope(); featureScope.run(() => { // All feature effects }); } function disableFeature() { featureScope?.stop(); featureScope = null; }
API Reference
batch(fn)
Batch multiple reactive updates into a single flush.
| Parameter | Type | Description |
|---|---|---|
fn | () => void | Function containing signal updates |
untrack(fn)
Execute a function without tracking dependencies.
| Parameter | Type | Description |
|---|---|---|
fn | () => T | Function to execute |
Returns: The return value of fn
toRaw(proxy)
Get the raw object from a reactive proxy.
| Parameter | Type | Description |
|---|---|---|
proxy | T | A reactive proxy |
Returns: The underlying raw object, or the value itself if not reactive
isReactive(value)
Check if a value is a reactive proxy.
| Parameter | Type | Description |
|---|---|---|
value | unknown | Value to check |
Returns: boolean
effectScope(detached?)
Create a scope for grouping effects.
| Parameter | Type | Default | Description |
|---|---|---|---|
detached | boolean | false | If true, scope is independent of parent |
Returns: EffectScope with run() and stop() methods
Best Practices
-
Use batch for bulk operations:
TSX// Good batch(() => { state.items = newItems; state.count = newItems.length; state.lastModified = Date.now(); }); -
Use untrack for initial reads:
TSXeffect(() => { const initial = untrack(() => state.initialValue); // Only track changes to state.current if (state.current !== initial) { // Handle change } }); -
Use toRaw for external APIs:
TSX// Always use toRaw when passing to non-SignalX code thirdPartyLib.process(toRaw(state.data)); -
Scope effects appropriately:
TSX// Group related effects for easy cleanup const featureScope = effectScope(); featureScope.run(() => { // Feature effects }); // One call stops everything featureScope.stop();
