Computed Values
Computed values are derived signals that automatically recalculate when their dependencies change. They're perfect for deriving state from other reactive sources.
Xote re-exports Computed from rescript-signals. The API and behavior are provided by that library.
Creating Computed Values
Use Computed.make() with a function that computes the derived value. It returns the computed signal:
open Xote
let firstName = Signal.make("John")
let lastName = Signal.make("Doe")
// Automatically updates when firstName or lastName changes
let fullName = Computed.make(() =>
Signal.get(firstName) ++ " " ++ Signal.get(lastName)
)
// Read the computed value directly from the signal
Console.log(Signal.get(fullName)) // "John Doe"
How Computed Values Work
Computed values are push-based (eager), not pull-based (lazy):
- When created, the computation runs immediately to establish dependencies
- When any dependency changes, the computed automatically recalculates
- The new value is pushed to a backing signal
- Any observers of the computed are notified
This means computed values are always up-to-date, but they may recalculate even if their value is never read.
Reading Computed Values
Computed values return a signal that can be read with Signal.get():
let count = Signal.make(5)
let doubled = Computed.make(() => Signal.get(count) * 2)
Console.log(Signal.get(doubled)) // Prints: 10
Signal.set(count, 10)
Console.log(Signal.get(doubled)) // Prints: 20
Automatic Disposal
Computed values automatically dispose when they lose all subscribers - you don't need to manually call Computed.dispose() in most cases!
let count = Signal.make(0)
let doubled = Computed.make(() => Signal.get(count) * 2)
// Create an effect that subscribes to doubled
let disposer = Effect.run(() => {
Console.log(Signal.get(doubled)) // doubled has 1 subscriber
None
})
Signal.set(count, 5) // doubled recomputes and logs
// Dispose the effect
disposer.dispose()
// ↑ doubled now has 0 subscribers - automatically disposed! ✨
Signal.set(count, 10)
// doubled doesn't recompute anymore (it was auto-disposed)
This works seamlessly with Components:
let app = () => {
let count = Signal.make(0)
let doubled = Computed.make(() => Signal.get(count) * 2)
<div>
{Component.textSignal(() => Signal.get(doubled)->Int.toString)}
</div>
}
// When the component unmounts:
// 1. The textSignal effect is disposed
// 2. doubled loses its last subscriber
// 3. doubled is automatically disposed ✨
Manual Disposal (Optional)
You can still manually dispose computeds when needed:
let count = Signal.make(0)
let doubled = Computed.make(() => Signal.get(count) * 2)
// Use it...
Console.log(Signal.get(doubled))
// Manually dispose when done
Computed.dispose(doubled)
Manual disposal is useful when:
- You want explicit control over lifecycle
- The computed has no subscribers but you want to stop it anyway
- You're managing complex dependency graphs manually
Chaining Computed Values
You can create computed values that depend on other computed values:
let price = Signal.make(100)
let quantity = Signal.make(3)
let subtotal = Computed.make(() =>
Signal.get(price) * Signal.get(quantity)
)
let tax = Computed.make(() =>
Signal.get(subtotal) * 0.1
)
let total = Computed.make(() =>
Signal.get(subtotal) + Signal.get(tax)
)
Console.log(Signal.get(total)) // 330
Signal.set(quantity, 5)
Console.log(Signal.get(total)) // 550
Example: Shopping Cart
Here's a practical example using computed values:
open Xote
type item = {
name: string,
price: float,
quantity: int,
}
let items = Signal.make([
{name: "Apple", price: 1.50, quantity: 3},
{name: "Banana", price: 0.75, quantity: 5},
])
let subtotal = Computed.make(() => {
Signal.get(items)
->Array.reduce(0.0, (acc, item) => {
acc +. item.price *. Int.toFloat(item.quantity)
})
})
let tax = Computed.make(() => Signal.get(subtotal) *. 0.08)
let total = Computed.make(() => Signal.get(subtotal) +. Signal.get(tax))
let app = Component.div(
~children=[
Component.h1(~children=[Component.text("Shopping Cart")], ()),
Component.list(items, item =>
Component.div(
~children=[
Component.text(
item.name ++ " - $" ++
Float.toString(item.price) ++ " x " ++
Int.toString(item.quantity)
)
],
()
)
),
Component.div(~children=[
Component.textSignal(() =>
"Subtotal: $" ++ Float.toString(Signal.get(subtotal))
)
], ()),
Component.div(~children=[
Component.textSignal(() =>
"Tax: $" ++ Float.toString(Signal.get(tax))
)
], ()),
Component.div(~children=[
Component.textSignal(() =>
"Total: $" ++ Float.toString(Signal.get(total))
)
], ()),
],
()
)
Component.mountById(app, "app")
Computed vs Manual Updates
Instead of manually updating derived state:
// ❌ Manual (error-prone)
let count = Signal.make(0)
let doubled = Signal.make(0)
let increment = () => {
Signal.update(count, n => n + 1)
Signal.set(doubled, Signal.get(count) * 2) // Easy to forget!
}
Use computed values for automatic updates:
// ✅ Automatic (safe)
let count = Signal.make(0)
let doubled = Computed.make(() => Signal.get(count) * 2)
let increment = () => {
Signal.update(count, n => n + 1)
// doubled automatically updates!
}
Dynamic Dependencies
Computed values re-track dependencies on every execution, so they adapt to control flow:
let useMetric = Signal.make(true)
let celsius = Signal.make(20)
let fahrenheit = Signal.make(68)
let temperature = Computed.make(() => {
if Signal.get(useMetric) {
Signal.get(celsius)
} else {
Signal.get(fahrenheit)
}
})
Console.log(Signal.get(temperature)) // 20
// Initially depends on: useMetric, celsius
Signal.set(useMetric, false)
// Now depends on: useMetric, fahrenheit
Console.log(Signal.get(temperature)) // 68
Best Practices
- Keep computations pure: Computed functions should not have side effects
- Use for derived state: Any value that can be calculated from other signals should be a computed
- Avoid expensive operations: Computed values recalculate eagerly, so keep them fast
- Don't nest effects: Computed values should not call
Effect.run()internally - Trust auto-disposal: In most cases, computeds will automatically clean up when their subscribers are disposed. Manual disposal is rarely needed
Important Notes
Cascading Auto-Disposal
Auto-disposal can cascade through chains of computeds:
let count = Signal.make(0)
let doubled = Computed.make(() => Signal.get(count) * 2)
let quadrupled = Computed.make(() => Signal.get(doubled) * 2)
let disposer = Effect.run(() => {
Console.log(Signal.get(quadrupled))
None
})
// Dependency chain: count → doubled → quadrupled → effect
disposer.dispose()
// Effect disposed → quadrupled has 0 subscribers → auto-dispose quadrupled
// → doubled has 0 subscribers → auto-dispose doubled ✨
This ensures the entire chain is cleaned up automatically when the leaf subscriber is removed!
Push-based, Not Lazy
Unlike some reactive systems, Xote's computed values are eager:
let count = Signal.make(0)
let expensive = Computed.make(() => {
Console.log("Computing...")
Signal.get(count) * 2
})
// "Computing..." is logged immediately
Signal.set(count, 5)
// "Computing..." is logged again, even if we never read 'expensive'
This ensures computed values are always current but may do unnecessary work if the computed is never observed. If this is a concern, dispose the computed when it's not needed:
let count = Signal.make(0)
let expensive = Computed.make(() => {
Console.log("Computing...")
Signal.get(count) * 2
})
// Use it...
Console.log(Signal.get(expensive))
// When done, stop recomputing
Computed.dispose(expensive)
Signal.set(count, 5)
// "Computing..." is NOT logged - disposed computed doesn't recompute
Next Steps
- Learn about Effects for side effects
- Understand Batching for grouping updates
- Try the Color Mixer Demo to see computed values in action