Effects#

Effects let you run side effects in response to signal changes. They're essential for data fetching, DOM manipulation, and synchronization.

Basic Effects#

TSX
import { signal, effect } from 'sigx';

const state = signal({ count: 0 });

// This runs immediately, then re-runs when count changes
effect(() => {
    console.log('Count is:', state.count);
});

state.count = 5;  // Logs: "Count is: 5"

Effects in Components#

TSX
import { component, effect } from 'sigx';

type UserProfileProps = Define.Prop<'userId', number, true>;

const UserProfile = component<UserProfileProps>(({ signal, props }) => {
    const state = signal({ user: null as any });
    
    // Fetch user when userId changes
    effect(() => {
        fetch(`/api/users/${props.userId}`)
            .then(r => r.json())
            .then(data => state.user = data);
    });
    
    return () => (
        <div>
            {state.user ? (
                <h1>{state.user.name}</h1>
            ) : (
                <p>Loading...</p>
            )}
        </div>
    );
});

Effect Cleanup#

In a component: disposal is automatic#

effect(), watch() and non-detached effectScope() created directly in a component's setup body are tied to that component's lifetime. They stop when it unmounts — and are re-created on HMR reload — so there is no handle to hold and nothing to call in onUnmounted:

TSX
const Search = component(() => {
    const query = signal('');

    // Stopped automatically when <Search /> unmounts.
    watch(() => query.value, (q) => runSearch(q));

    return () => <input onInput={(e) => (query.value = e.target.value)} />;
});

Only the setup body is scoped. A reaction created inside onMounted, onCreated, a setTimeout, or any async callback runs after setup has finished collecting, so it is not captured — dispose those yourself:

TSX
const Feed = component(() => {
    onMounted(() => {
        const runner = effect(() => console.log(count.value));
        onUnmounted(() => runner.stop());   // ← still your job
    });

    return () => <div />;
});

computed() is lazy and holds no subscription of its own, so it never needs disposal.

Outside a component: stop it yourself#

With no component setup to attach to — module scope, a plugin's install(), an event handler — the returned EffectRunner is the only thing that can stop the effect:

TSX
const runner = effect(() => {
    const timer = setInterval(() => {
        console.log('Tick');
    }, 1000);
});

// Later: stop the effect
runner.stop();

Watch API#

For more control over when callbacks fire, use watch:

TSX
import { signal, watch } from 'sigx';

const state = signal({ count: 0 });

// Watch a specific value
const stop = watch(
    () => state.count,
    (newValue, oldValue, onCleanup) => {
        console.log(`Changed from ${oldValue} to ${newValue}`);
        
        // Optional cleanup
        onCleanup(() => {
            console.log('Cleaning up...');
        });
    },
    { immediate: true }  // Run immediately with current value
);

// Control the watcher
stop.pause();   // Pause watching
stop.resume();  // Resume watching
stop.stop();    // Stop completely

Watch Options#

TSX
watch(source, callback, {
    immediate: true,  // Run callback immediately
    deep: true,       // Deep watch nested objects
    once: true        // Only trigger once
});

Batching Updates#

Use batch to group multiple updates:

TSX
import { signal, batch, effect } from 'sigx';

const state = signal({ a: 1, b: 2 });

effect(() => {
    console.log('Sum:', state.a + state.b);
});

// Without batch: logs twice
state.a = 10;  // Logs
state.b = 20;  // Logs

// With batch: logs once
batch(() => {
    state.a = 100;
    state.b = 200;
});
// Logs once: "Sum: 300"

Effect Scope#

Group and manage multiple effects:

TSX
import { effectScope, effect } from 'sigx';

const scope = effectScope();

scope.run(() => {
    effect(() => { /* tracked */ });
    effect(() => { /* tracked */ });
});

// Dispose all effects at once
scope.stop();

A scope created in a component's setup body is itself scoped to that component, so it stops on unmount along with everything inside it. Pass effectScope(true) to detach it — a detached scope opts out of that capture and is yours to stop.

When to Use Effects#

Good use cases:

  • Data fetching
  • DOM manipulation
  • Event listeners
  • Timers and intervals
  • Syncing with external systems

Avoid:

  • Computing derived values (use computed instead)
  • Modifying other signals (can cause infinite loops)
  • Modifying signals within the same effect (can cause loops)

Next Steps#