signals

Fine-grained reactive signals for MoonBit (alien-signals inspired)

signals
reactive
alien-signals
ui
vnode
moon add mizchi/signals@0.6.5
Download zip
Author
Version
0.6.5
License
MIT
Last updated
last month
Downloads
76K
README

#mizchi/signals

Fine-grained reactive signals library for MoonBit. Inspired by alien-signals and Solid.js.

#What are Reactive Signals?

Reactive signals provide a way to manage state that automatically tracks dependencies and propagates changes. When a signal's value changes, all computations and effects that depend on it are automatically updated.

Key benefits:
  • Automatic dependency tracking: No need to manually specify what depends on what
  • Efficient updates: Only affected computations re-run when values change
  • Composable: Build complex reactive graphs from simple primitives

Use cases:
  • UI frameworks: Automatically update views when data changes
  • State management: Manage application state with predictable updates
  • Data pipelines: Create derived values that stay in sync with source data
  • Game development: React to game state changes efficiently
  • Real-time systems: Propagate sensor/input changes through a system

#Installation

moon add mizchi/signals

#API

#Signal

Holds a reactive value and notifies subscribers when it changes.

let count = signal(0)

// Get value (auto-tracked inside effects)
count.get() // => 0

// Set value
count.set(5)

// Update with function
count.update(fn(n) { n + 1 })

// Get without tracking (doesn't create dependency)
count.peek()

#memo / computed

Creates a memoized value that recomputes only when dependencies change.

let a = signal(2)
let b = signal(3)

let sum = memo(fn() { a.get() + b.get() })
sum() // => 5

a.set(10)
sum() // => 13 (recomputed)
sum() // => 13 (cached value)

computed is an alias for memo.

#render_effect

Creates a side effect that re-runs synchronously when signals change.

let count = signal(0)

let dispose = render_effect(fn() {
println("count = " + count.get().to_string())
})

count.set(1) // prints "count = 1"
count.set(2) // prints "count = 2"

dispose() // stop the effect

#effect

Similar to render_effect, but the initial execution is deferred via microtask queue (Solid.js createEffect style).

let count = signal(0)
let dispose = effect(fn() {
println("deferred: " + count.get().to_string())
})
// Initial execution happens after current synchronous code completes

Note: effect uses queue_microtask, which is environment-specific. See "Environment-specific async behavior" below.

#batch

Batches multiple updates so effects run only once.

let a = signal(0)
let b = signal(0)

let _ = render_effect(fn() {
println("sum = " + (a.get() + b.get()).to_string())
})

batch(fn() {
a.set(1)
b.set(2)
})
// Effect runs only once

#on_cleanup

Registers a cleanup function inside an effect. Called before the effect re-runs or when disposed.

let _ = render_effect(fn() {
let id = set_interval(...)
on_cleanup(fn() {
clear_interval(id)
})
})

#create_root

Creates a reactive scope root. Calling dispose stops all effects within.

create_root(fn(dispose) {
let _ = render_effect(fn() { ... })
let _ = render_effect(fn() { ... })

// Stop all effects at once
dispose()
})

#untracked

Runs a function with tracking disabled. Signal reads won't create dependencies.

let _ = render_effect(fn() {
// This signal read won't create a dependency
untracked(fn() {
let _ = some_signal.get()
})
})

#Environment-specific async behavior

This library uses queue_microtask for deferred effects (the effect function), but microtask scheduling is environment-specific.

  • JS target: Uses native queueMicrotask from browser/Node.js
  • Non-JS targets (wasm/native): Falls back to immediate execution

For production use requiring deferred execution, consider implementing environment-appropriate async handling. If synchronous behavior is sufficient, use render_effect instead.

#License

MIT

#
Snapshot

pub trait Snapshot {
fn snapshot(Self) -> Self
}

Trait for signal records that can produce a snapshot

#
Computed

pub(all) struct Computed[T] {
node : ReactiveNode
value : T?
getter : () -> T
}

Computed node - derives value from dependencies (internal)

#
Computed::get

fn[T] Computed::get(self : Computed[T]) -> T

Get the computed value Automatically tracks dependency if called inside an effect/computed

#
Computed::peek

fn[T] Computed::peek(self : Computed[T]) -> T?

Peek at current value without tracking

#
Context

pub struct Context[T] {
id : Int
default_value : () -> T
providers : Array[(Int, () -> T)]
}

Context type with typed getter The current_getter is managed per-context and tracks the current value chain

#
CoreSignal

pub(all) struct CoreSignal[T] {
node : ReactiveNode
current_value : T
pending_value : T
}

CoreSignal - internal reactive value container (use Signal for public API)

#
CoreSignal::clear_subscribers

fn[T] CoreSignal::clear_subscribers(self : CoreSignal[T]) -> Unit

Clear all subscribers

#
CoreSignal::get

fn[T] CoreSignal::get(self : CoreSignal[T]) -> T

Get the current value of a core signal Automatically tracks dependency if called inside an effect/computed

#
CoreSignal::peek

fn[T] CoreSignal::peek(self : CoreSignal[T]) -> T

Get value without tracking (peek) Returns the latest value (pending if dirty, otherwise current)

#
CoreSignal::set

fn[T] CoreSignal::set(self : CoreSignal[T], value : T) -> Unit

Set a new value for the core signal Notifies all subscribers if value changed

#
CoreSignal::subscriber_count

fn[T] CoreSignal::subscriber_count(self : CoreSignal[T]) -> Int

Get number of subscribers (for debugging)

#
CoreSignal::update

fn[T] CoreSignal::update(self : CoreSignal[T], f : (T) -> T) -> Unit

Update core signal value using a function

#
EffectNode

pub(all) struct EffectNode {
node : ReactiveNode
run_fn : () -> Unit
}

Effect node - runs side effects

#
Flags

pub(all) struct Flags {
value : Int
}

Bitwise flag set

#
Flags::get_value

fn Flags::get_value(self : Flags) -> Int

#
Flags::has

fn Flags::has(self : Flags, flag : ReactiveFlags) -> Bool

#
Flags::has_any

fn Flags::has_any(self : Flags, flags : Array[ReactiveFlags]) -> Bool

#
Flags::new

fn Flags::new(initial : Int) -> Flags

#
Flags::set

fn Flags::set(self : Flags, flag : ReactiveFlags) -> Unit

#
Flags::set_value

fn Flags::set_value(self : Flags, v : Int) -> Unit

#
Flags::unset

fn Flags::unset(self : Flags, flag : ReactiveFlags) -> Unit

#
Lens

pub struct Lens[S, A] {
get : (S) -> A
set : (S, A) -> S
}

A lens focuses on a specific part of a larger structure

#
Lens::new

fn[S, A] Lens::new(get : (S) -> A, set : (S, A) -> S) -> Lens[S, A]

#
LensStore

pub struct LensStore[T] {
source : Signal[T]
}

A store with lens-based field access Each focused lens creates a derived signal that only updates when that field changes

#
LensStore::focus

fn[T, A : Eq] LensStore::focus(self : LensStore[T], lens : Lens[T, A]) -> (Signal[A], (A) -> Unit)

Focus on a field using a lens, returns getter and setter functions The getter is memoized and only recomputes when the focused value changes Uses render_effect for synchronous updates

#
LensStore::new

fn[T] LensStore::new(initial : T) -> LensStore[T]

#
LensStore::snapshot

fn[T] LensStore::snapshot(self : LensStore[T]) -> T

Get the current snapshot of the entire store

#
LensStore::update

fn[T] LensStore::update(self : LensStore[T], f : (T) -> T) -> Unit

Update the entire store
pub(all) struct Link {
version : Int
dep : ReactiveNode
sub : ReactiveNode
prev_sub : Link?
next_sub : Link?
prev_dep : Link?
next_dep : Link?
}

Double-linked list node connecting dependencies and subscribers

#
Owner

pub struct Owner {
id : Int
parent : Owner?
children : Array[Owner]
cleanups : Array[() -> Unit]
disposers : Array[() -> Unit]
disposed : Bool
}

Owner - manages lifecycle of reactive computations (Solid.js style)

#
Owner::dispose

fn Owner::dispose(self : Owner) -> Unit

Dispose this owner and all its children

#
Owner::new

fn Owner::new(parent : Owner?) -> Owner

Create a new Owner

#
ReactiveFlags

pub(all) enum ReactiveFlags {
None
Mutable
Watching
RecursedCheck
Recursed
Dirty
Pending
}

Reactive flags for tracking node state

#
ReactiveFlags::to_int

fn ReactiveFlags::to_int(self : ReactiveFlags) -> Int

Convert flags to int for bitwise operations

#
ReactiveNode

pub(all) struct ReactiveNode {
deps : Link?
deps_tail : Link?
subs : Link?
subs_tail : Link?
flags : Flags
last_modified_cycle : Int
effect_callback : () -> Unit?
}

Base reactive node - can be signal, computed, or effect

#
ReactiveNode::new

fn ReactiveNode::new(flags : Int) -> ReactiveNode

#
Signal

pub struct Signal[T] {
inner : CoreSignal[T]
}

Signal type - holds a value and notifies subscribers on change. A reactive primitive that stores a value and automatically tracks dependencies.

#
Signal::clear_subscribers

fn[T] Signal::clear_subscribers(self : Signal[T]) -> Unit

Clear all subscribers (for cleanup)

#
Signal::get

fn[T] Signal::get(self : Signal[T]) -> T

Get the current value and track dependency if inside an effect. When called inside an effect or memo, automatically subscribes to changes.

#
Signal::new

fn[T] Signal::new(initial : T) -> Signal[T]

Create a new signal with initial value.

#
Signal::peek

fn[T] Signal::peek(self : Signal[T]) -> T

Get value without tracking (won't create dependency). Useful when you need to read a signal without subscribing to it.

#
Signal::set

fn[T] Signal::set(self : Signal[T], new_value : T) -> Unit

Set a new value and notify all subscribers.

#
Signal::subscriber_count

fn[T] Signal::subscriber_count(self : Signal[T]) -> Int

Get number of subscribers (for debugging)

#
Signal::update

fn[T] Signal::update(self : Signal[T], f : (T) -> T) -> Unit

Update the value using a function. Useful for updating based on current value.

#
SplitStore2

pub struct SplitStore2[A, B] {
field1 : Signal[A]
field2 : Signal[B]
}

A record where each field is an independent Signal This is the most performant approach - updates only affect specific fields

#
SplitStore2::new

fn[A, B] SplitStore2::new(v1 : A, v2 : B) -> SplitStore2[A, B]

#
SplitStore3

pub struct SplitStore3[A, B, C] {
field1 : Signal[A]
field2 : Signal[B]
field3 : Signal[C]
}

3-field version

#
SplitStore3::new

fn[A, B, C] SplitStore3::new(v1 : A, v2 : B, v3 : C) -> SplitStore3[A, B, C]

#
all

fn all(signals : Array[Signal[Bool]]) -> (() -> Bool)

Create a signal that is true when all input signals are true.

#
any

fn any(signals : Array[Signal[Bool]]) -> (() -> Bool)

Create a signal that is true when any input signal is true.

#
batch

fn[T] batch(f : () -> T) -> T

Run a function in a batch - all signal updates are batched. Effects only run once after all updates complete.

#
batch_end

fn batch_end() -> Unit

End a batch update - run all pending effects

#
batch_start

fn batch_start() -> Unit

Start a batch update - effects won't run until batch ends

#
check_dirty

fn check_dirty(start_link : Link, sub : ReactiveNode, update_fn : (ReactiveNode) -> Bool) -> Bool

Check if any dependency is dirty and needs update

#
combine2

fn[A, B, R] combine2(a : Signal[A], b : Signal[B], f : (A, B) -> R) -> (() -> R)

Combine two signals into one. The getter recomputes when either signal changes.

#
combine3

fn[A, B, C, R] combine3(a : Signal[A], b : Signal[B], c : Signal[C], f : (A, B, C) -> R) -> (() -> R)

Combine three signals into one

#
combine4

fn[A, B, C, D, R] combine4(a : Signal[A], b : Signal[B], c : Signal[C], d : Signal[D], f : (A, B, C, D) -> R) -> (() -> R)

Combine four signals into one

#
computed

fn[T] computed(compute : () -> T) -> (() -> T)

Create a computed signal (alias for memo with Signal-like API). Same as memo, but named to be more familiar for those coming from other reactive frameworks.

#
core_signal

fn[T] core_signal(initial : T) -> CoreSignal[T]

Create a new core signal with initial value

#
create_context

fn[T] create_context(default_value : T) -> Context[T]

Create a new context with a default value Similar to Solid.js createContext

#
create_root

fn[T] create_root(f : (() -> Unit) -> T) -> T

Create a new reactive root scope. The function receives a dispose callback that cleans up all effects. Returns the result of the function.

#
create_root_with_dispose

fn[T] create_root_with_dispose(f : () -> T) -> (T, () -> Unit)

Create a reactive root and return both result and dispose function

#
effect

fn effect(fn_ : () -> Unit) -> (() -> Unit)

Create an effect that is deferred until after rendering completes. (Solid.js style createEffect - deferred execution via microtask) Returns a dispose function to stop the effect. Unlike render_effect, this runs asynchronously via microtask queue.

#
effect_once

fn effect_once(fn_ : () -> Unit) -> Unit

Create a one-time effect that disposes itself after first run. Useful for initialization logic that should only run once.

#
effect_when

fn effect_when(condition : () -> Bool, fn_ : () -> Unit) -> (() -> Unit)

Create an effect that only runs when condition is true. Uses render_effect for synchronous execution.

#
end_batch

fn end_batch() -> Unit

End a batch update

#
flatten

fn[T] flatten(outer : Signal[Signal[T]]) -> (() -> T)

Flatten a signal of signals. Unwraps nested Signal[Signal[T]] to a getter for T.

#
flush

fn flush() -> Unit

Flush the effect queue

#
get_active_sub

fn get_active_sub() -> ReactiveNode?

Get current active subscriber

#
get_batch_depth

fn get_batch_depth() -> Int

Get current batch depth

#
get_cycle

fn get_cycle() -> Int

Get current cycle

#
get_owner

fn get_owner() -> Owner?

Get the current owner (if any)

#
has_owner

fn has_owner() -> Bool

Check if currently inside an owner scope

#
inc_cycle

fn inc_cycle() -> Unit

Increment cycle

#
is_batching

fn is_batching() -> Bool

Check if we're currently inside a batch
fn link(dep : ReactiveNode, sub : ReactiveNode, version : Int) -> Unit

Create a link between dependency and subscriber

#
memo

fn[T] memo(compute : () -> T) -> (() -> T)

Create a memoized computation. Returns a getter function that caches the result and recomputes only when dependencies change.

#
new_effect_id

fn new_effect_id() -> Int

Generate a new unique effect ID
fn[T] on(sig : Signal[T], callback : (T) -> Unit) -> (() -> Unit)

Explicitly subscribe to a signal with a callback. Returns an unsubscribe function. Unlike effect(), this doesn't auto-track and only listens to this one signal. Callback is NOT called on initial subscription, only on subsequent changes.

#
on_cleanup

fn on_cleanup(cleanup : () -> Unit) -> Unit

Register a cleanup function to run when the current scope disposes. Works in:
  • Effects: cleanup runs before effect re-runs or when effect disposes
  • Components: cleanup runs when component unmounts (Solid.js style - can be called directly in component body)

#
on_immediate

fn[T] on_immediate(sig : Signal[T], callback : (T) -> Unit) -> (() -> Unit)

Subscribe and run immediately with current value. Like on, but also invokes the callback with the current value right away.

#
on_mount

fn on_mount(fn_ : () -> Unit) -> Unit

Run a function once after mount (Solid.js style onMount). The function runs without tracking dependencies. Cleanup registered via on_cleanup inside will run when owner is disposed.
fn[T] previous(sig : Signal[T]) -> (() -> T?)

Create a signal that holds the previous value of another signal. Returns a getter function for the previous value (None on first read).

#
previous_with_initial

fn[T] previous_with_initial(sig : Signal[T], initial : T) -> (() -> T)

Create a signal that holds the previous value with initial value. Unlike previous, this returns a plain T instead of T?.

#
propagate

fn propagate(start_link : Link) -> Unit

Propagate changes through the dependency graph (simplified version)

#
provide

fn[T, R] provide(ctx : Context[T], value : T, f : () -> R) -> R

Provide a context value for the current Owner and its descendants The value is associated with the current Owner and will be available to all effects and components created within this scope.

#
purge_deps

fn purge_deps(sub : ReactiveNode) -> Unit

Purge unused deps after deps_tail

#
queue_effect

fn queue_effect(eff : EffectNode) -> Unit

Queue an effect for execution

#
register_disposer

fn register_disposer(disposer : () -> Unit) -> Unit

Register a disposer with the current owner Called internally by effect() to register its dispose function

#
register_owner_cleanup

fn register_owner_cleanup(cleanup : () -> Unit) -> Unit

Register a cleanup with the current owner (alternative to onCleanup in effect)

#
render_effect

fn render_effect(fn_ : () -> Unit) -> (() -> Unit)

Create an effect that runs immediately and re-runs when dependencies change. (Solid.js style createRenderEffect - synchronous execution) Returns a dispose function to stop the effect. Supports on_cleanup() calls inside the effect.

#
run_with_cleanup_tracking

fn[T] run_with_cleanup_tracking(cleanups : Array[() -> Unit], f : () -> T) -> T

Run a function with cleanup tracking enabled

#
run_with_owner

fn[T] run_with_owner(owner : Owner, f : () -> T) -> T

Run a function with a specific owner as current

#
select

fn[T] select(items : Signal[Array[T]], index : Signal[Int]) -> (() -> T?)

Select from an array signal by index signal. Returns None if index is out of bounds.

#
set_active_sub

fn set_active_sub(sub : ReactiveNode?) -> ReactiveNode?

Set active subscriber, returns previous

#
set_current_cleanups

fn set_current_cleanups(cleanups : Array[() -> Unit]?) -> Array[() -> Unit]?

Set the current cleanup array (used internally by effect)

#
shallow_propagate

fn shallow_propagate(start_link : Link) -> Unit

Shallow propagate - mark direct subscribers as dirty

#
sig_filter

fn[T] sig_filter(sig : Signal[T], predicate : (T) -> Bool) -> Signal[T?]

Filter signal updates - only updates when predicate is true. Returns a signal that only updates when predicate passes.

#
sig_filter_map

fn[T, U] sig_filter_map(sig : Signal[T], f : (T) -> U?) -> Signal[U?]

Filter and map signal updates Uses render_effect for synchronous execution

#
sig_map

fn[T, U] sig_map(sig : Signal[T], f : (T) -> U) -> (() -> U)

Map a signal's value through a function. Returns a getter function (like memo) that recomputes when the signal changes.

#
signal

fn[T] signal(initial : T) -> Signal[T]

Convenience function to create a signal. Shorthand for Signal::new.

#
start_batch

fn start_batch() -> Unit

Start a batch update

#
switch_

fn[T] switch_(condition : Signal[Bool], on_true : Signal[T], on_false : Signal[T]) -> (() -> T)

Switch between signals based on a boolean signal. Returns value from on_true when condition is true, otherwise on_false.

#
to_getter

fn[T] to_getter(sig : Signal[T]) -> (() -> T)

Create a read-only getter for a signal
fn unlink(lnk : Link, sub : ReactiveNode) -> Link?

Unlink a dependency link, returns next dep link

#
untracked

fn[T] untracked(f : () -> T) -> T

Run a function without tracking (useful for avoiding circular deps). Signal reads inside this function won't create subscriptions.

#
use_context

fn[T] use_context(ctx : Context[T]) -> T

Use a context value - returns the current provided value or default Walks up the Owner chain to find the nearest provided value. Similar to Solid.js useContext

#
watch

fn[T : Eq] watch(source : () -> T, callback : (T, T) -> Unit) -> (() -> Unit)

Watch a computed expression and run callback when it changes. Returns a dispose function. The callback receives (newValue, oldValue).

#
watch_immediate

fn[T : Eq] watch_immediate(source : () -> T, callback : (T, T?) -> Unit) -> (() -> Unit)

Watch with immediate execution (runs callback on first value too). The callback receives (newValue, oldValue?) where oldValue is None on first run.

#
with_parent_owner

fn[T] with_parent_owner(captured_owner : Owner?, f : () -> T) -> T

Run a function with a captured parent owner context (if any) This is useful for rendering child components that need to inherit the parent's owner. If captured_owner is Some, runs with that owner as context. If captured_owner is None, runs directly without owner context.

Powered by MoonBit

Site sourceReport issuePackagesBuild queueSkillsStatistics

© 2026 mooncakes.io