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:

TSX
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
TSX
// 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:

TSX
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:

TSX
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. Changing state.a from 1 to 2 doesn'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():

TSX
// 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:

TSX
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#

  1. One-time reads - Read initial/default values:

    TSX
    const initialValue = untrack(() => config.defaultValue);
  2. Avoid circular dependencies:

    TSX
    effect(() => {
        const current = state.value;
        // Use untrack to avoid triggering this effect again
        untrack(() => history.push(current));
    });
  3. Conditional dependencies:

    TSX
    effect(() => {
        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:

TSX
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:

TSX
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:

TSX
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:

TSX
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:

TSX
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#

  1. Component cleanup - Group all component effects:

    TSX
    const MyComponent = component((ctx) => {
        const scope = effectScope();
        
        ctx.onMounted(() => {
            scope.run(() => {
                effect(() => { /* ... */ });
                effect(() => { /* ... */ });
            });
        });
        
        ctx.onUnmounted(() => {
            scope.stop();
        });
    });
  2. Feature modules - Enable/disable features:

    TSX
    let 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.

ParameterTypeDescription
fn() => voidFunction containing signal updates

untrack(fn)#

Execute a function without tracking dependencies.

ParameterTypeDescription
fn() => TFunction to execute

Returns: The return value of fn

toRaw(proxy)#

Get the raw object from a reactive proxy.

ParameterTypeDescription
proxyTA reactive proxy

Returns: The underlying raw object, or the value itself if not reactive

isReactive(value)#

Check if a value is a reactive proxy.

ParameterTypeDescription
valueunknownValue to check

Returns: boolean

effectScope(detached?)#

Create a scope for grouping effects.

ParameterTypeDefaultDescription
detachedbooleanfalseIf true, scope is independent of parent

Returns: EffectScope with run() and stop() methods

Best Practices#

  1. Use batch for bulk operations:

    TSX
    // Good
    batch(() => {
        state.items = newItems;
        state.count = newItems.length;
        state.lastModified = Date.now();
    });
  2. Use untrack for initial reads:

    TSX
    effect(() => {
        const initial = untrack(() => state.initialValue);
        // Only track changes to state.current
        if (state.current !== initial) {
            // Handle change
        }
    });
  3. Use toRaw for external APIs:

    TSX
    // Always use toRaw when passing to non-SignalX code
    thirdPartyLib.process(toRaw(state.data));
  4. Scope effects appropriately:

    TSX
    // Group related effects for easy cleanup
    const featureScope = effectScope();
    featureScope.run(() => {
        // Feature effects
    });
    // One call stops everything
    featureScope.stop();

Next Steps#