Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Effects

An Effect is a side-effect that runs when its dependencies change. It tracks which signals it reads and re-runs when any of them update. No dependency arrays, no manual subscription — just read a signal and you’re subscribed.

You probably don’t need this. For reactive DOM updates, use {|| expr} closures in RSX. For state mutations, use store methods. Effect is for the rare case where you need to react to signal changes outside of both — logging, syncing to external systems, etc.

Effect is intentionally excluded from the prelude. Import explicitly:

#![allow(unused)]
fn main() {
use rinch::reactive::Effect;
}

Creating Effects

#![allow(unused)]
fn main() {
let count = Signal::new(0);

// Runs immediately, then re-runs when count changes
Effect::new(move || {
    println!("Count is: {}", count.get());
});

count.set(1); // Prints: "Count is: 1"
count.set(2); // Prints: "Count is: 2"
}

Signal is Copy, so you can use count in the closure and still use it elsewhere. No .clone().

When Effects Run

  1. Immediately on creation — the effect runs once right away
  2. When dependencies change — whenever a tracked signal is updated
#![allow(unused)]
fn main() {
let a = Signal::new(1);
let b = Signal::new(2);

Effect::new(move || {
    println!("a = {}, b = {}", a.get(), b.get());
});
// Prints: "a = 1, b = 2"

a.set(10); // Prints: "a = 10, b = 2"
b.set(20); // Prints: "a = 10, b = 20"
}

Conditional Dependencies

Dependencies are discovered at runtime, not declared statically. If a signal is only read in one branch, the subscription only exists when that branch executes:

#![allow(unused)]
fn main() {
let show_details = Signal::new(false);
let name = Signal::new("Alice".to_string());
let age = Signal::new(30);

Effect::new(move || {
    println!("Name: {}", name.get());
    if show_details.get() {
        println!("Age: {}", age.get()); // only tracked when show_details is true
    }
});

age.set(31);           // Nothing happens — age isn't tracked yet
show_details.set(true); // Re-runs, now age IS tracked
age.set(32);           // Re-runs — age is tracked now
}

Common Patterns

Logging

#![allow(unused)]
fn main() {
let count = Signal::new(0);
Effect::new(move || {
    println!("[DEBUG] count = {}", count.get());
});
}

Syncing to External Systems

#![allow(unused)]
fn main() {
let theme = Signal::new("light".to_string());
Effect::new(move || {
    update_css_theme(&theme.get());
});
}

Derived Side Effects

#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3]);
Effect::new(move || {
    let count = items.with(|v| v.len());
    update_badge(count);
});
}

Disposing Effects

An effect stops when it is disposed — explicitly, or by the scope that owns it:

#![allow(unused)]
fn main() {
let effect = Effect::new(move || {
    println!("Count: {}", count.get());
});

effect.dispose(); // Effect stops running
count.set(1);     // Nothing happens
}

Dropping the handle does not dispose the effect. This is deliberate and it is what every example on this page relies on: Effect::new(move || …); with no let drops the returned handle immediately, and the effect keeps running anyway. The closure lives in a registry keyed by id, not in the handle, because an effect is queued and run by id long after the caller has forgotten it.

So an effect has exactly two ends:

  • Explicit — effect.dispose().
  • Owned — an effect created during a render belongs to that render, and is disposed when the component unmounts, the if branch flips, or the for row is reconciled away. This is the usual case, and it is why RSX closures never need cleaning up by hand.

An effect created with no ambient owner and no retained handle — from main(), from startup code, from a detached callback — runs for the life of the thread. That is the right default for app-wide wiring and a leak everywhere else, so prefer creating effects during a render, where something owns them.

Disposal is what reclaims the registry entry, not merely what silences it: everything the closure captured is dropped with it.

Pitfalls

Don’t create effects inside effects. The inner effect gets recreated every time the outer one re-runs.

Don’t modify a signal you read in the same effect. That’s an infinite loop. Use untracked(|| signal.get()) if you need to read without subscribing.

#![allow(unused)]
fn main() {
// BAD: infinite loop
Effect::new(move || {
    let val = count.get();
    count.set(val + 1); // triggers re-run -> reads count -> triggers re-run -> ...
});

// OK: read without subscribing
Effect::new(move || {
    let val = untracked(|| count.get());
    do_something(val);
});
}