Effects
Effects are functions that run side effects in response to reactive state changes. They automatically re-execute when any signal they depend on changes.
Creating Effects
Use Effect.run() to create an effect:
open Xote
let count = Signal.make(0)
Effect.run(() => {
Console.log2("Count is now:", Signal.get(count))
})
// Prints: "Count is now: 0"
Signal.set(count, 1)
// Prints: "Count is now: 1"
How Effects Work
- The effect function runs immediately when created
- Any
Signal.get()calls during execution are tracked as dependencies - When a dependency changes, the effect re-runs
- Dependencies are re-tracked on every execution
Common Use Cases
DOM Updates
Effects are great for manual DOM manipulation:
let color = Signal.make("red")
Effect.run(() => {
let element = Document.getElementById("box")
switch element {
| Some(el) => el->Element.setStyle("backgroundColor", Signal.get(color))
| None => ()
}
})
Logging and Debugging
Track state changes for debugging:
let user = Signal.make({id: 1, name: "Alice"})
Effect.run(() => {
let currentUser = Signal.get(user)
Console.log2("User changed:", currentUser)
})
Synchronization
Sync reactive state with external systems:
let settings = Signal.make({theme: "dark", language: "en"})
Effect.run(() => {
let current = Signal.get(settings)
// Save to localStorage
LocalStorage.setItem("settings", JSON.stringify(current))
})
Disposing Effects
Effect.run() returns a disposer object with a dispose() method to stop the effect:
let count = Signal.make(0)
let disposer = Effect.run(() => {
Console.log(Signal.get(count))
})
Signal.set(count, 1) // Effect runs
Signal.set(count, 2) // Effect runs
disposer.dispose() // Stop the effect
Signal.set(count, 3) // Effect does NOT run
Dynamic Dependencies
Effects re-track dependencies on each execution, adapting to conditional logic:
let showDetails = Signal.make(false)
let name = Signal.make("Alice")
let age = Signal.make(30)
Effect.run(() => {
Console.log(Signal.get(name))
if Signal.get(showDetails) {
Console.log2("Age:", Signal.get(age))
}
})
// Initially depends on: name, showDetails
// After setting showDetails to true, depends on: name, showDetails, age
Avoiding Dependencies
Use Signal.peek() or Core.untrack() to read signals without creating dependencies:
Using peek()
let count = Signal.make(0)
let debug = Signal.make(true)
Effect.run(() => {
Console.log2("Count:", Signal.get(count))
// Read debug flag without depending on it
if Signal.peek(debug) {
Console.log("Debug mode is on")
}
})
Using untrack()
let count = Signal.make(0)
let logger = Signal.make(Console.log)
Effect.run(() => {
let value = Signal.get(count)
// Run code without tracking dependencies
Core.untrack(() => {
let logFn = Signal.get(logger)
logFn(value)
})
})
Example: Auto-save
Here's a practical example of an auto-save effect:
open Xote
type draft = {
title: string,
content: string,
}
let draft = Signal.make({
title: "",
content: "",
})
let saveStatus = Signal.make("Saved")
// Auto-save effect with debouncing
let timeoutId = ref(None)
Effect.run(() => {
let current = Signal.get(draft)
// Cancel previous timeout
switch timeoutId.contents {
| Some(id) => clearTimeout(id)
| None => ()
}
Signal.set(saveStatus, "Unsaved changes...")
// Save after 1 second of no changes
timeoutId := Some(setTimeout(() => {
// Save to server
saveToServer(current)
Signal.set(saveStatus, "Saved")
}, 1000))
})
Nested Effects
You can create effects inside other effects, but be careful:
let outer = Signal.make(0)
let inner = Signal.make(0)
Effect.run(() => {
Console.log2("Outer:", Signal.get(outer))
// This creates a new effect each time outer changes!
Effect.run(() => {
Console.log2("Inner:", Signal.get(inner))
})
})
Tip: Avoid creating effects inside effects unless you're cleaning them up properly. Usually, a single effect with multiple dependencies is clearer.
Best Practices
- Keep effects focused: Each effect should do one thing
- Clean up resources: Use the disposer when effects are no longer needed
- Avoid infinite loops: Don't set signals that the effect depends on
- Use for side effects only: Effects should not compute values (use Computed instead)
- Handle errors: Wrap effect code in try-catch if it might throw
Common Pitfalls
Infinite Loop
// ❌ DON'T: Creates infinite loop
let count = Signal.make(0)
Effect.run(() => {
Signal.update(count, n => n + 1) // Triggers itself!
})
Not Disposing
// ❌ DON'T: Creates memory leak in components
let createComponent = () => {
Effect.run(() => {
// ...
})
// Effect never cleaned up!
}
// ✅ DO: Store and clean up disposers
let createComponent = () => {
let disposer = Effect.run(() => {
// ...
})
let cleanup = () => {
disposer.dispose()
}
(component, cleanup)
}
Effects vs Computed
| Feature | Effect | Computed |
|---|---|---|
| Purpose | Side effects | Derive values |
| Returns | Disposer | Signal |
| When runs | Immediately and on changes | Immediately and on changes |
| Result | None (performs actions) | New reactive value |
Use Computed for pure calculations, Effects for side effects.
Next Steps
- Learn about Batching to optimize multiple updates
- See how effects work in Components
- Try the Reaction Game Demo to see effects with timers