A lightweight, glitch-free reactive signal library for Kotlin Multiplatform, inspired by SolidJS signals and Kotlin StateFlow.
| JVM | JS | WasmJS | WasmWASI | Linux x64 | macOS x64 | macOS ARM64 | Windows x64 |
|---|---|---|---|---|---|---|---|
| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
import io.github.fenrur.signal.*
import io.github.fenrur.signal.operators.*
val count = mutableSignalOf(0)
val doubled = count.map { it * 2 }
doubled.subscribe { it.onSuccess { v -> println("doubled = $v") } }
// prints: doubled = 0
count.value = 5
// prints: doubled = 10- Glitch-free propagation -- derived signals never observe inconsistent intermediate values, even in diamond dependency graphs
- Thread-safe -- all signal implementations are safe for concurrent reads, writes, and subscriptions
- Rich operator library -- 60+ built-in operators: map, filter, combine, flatMap, scan, boolean, numeric, string, collection, and more
- Batching -- group multiple signal updates together to defer notifications and avoid intermediate states
- Bindable signals -- dynamically switch the source signal at runtime with automatic circular binding detection
- Property delegates -- use signals as Kotlin
bydelegates for seamless integration - Coroutines Flow interop -- bidirectional conversion between Signal and
StateFlow/MutableStateFlow - Reactive Streams interop -- convert to/from
org.reactivestreams.Publisher(JVM) - Zero mandatory dependencies -- only
kotlinx-coroutines-corefor Flow/StateFlow interop
- Installation
- Core Concepts
- Operators
- Batching
- Glitch-Free Semantics
- Integrations
- Thread Safety
- License
// build.gradle.kts
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.fenrur:signal:3.0.0")
}
}
}
}dependencies {
implementation("io.github.fenrur:signal-jvm:3.0.0")
}<dependency>
<groupId>io.github.fenrur</groupId>
<artifactId>signal-jvm</artifactId>
<version>3.0.0</version>
</dependency>A Signal<T> is a read-only reactive container that holds a value and notifies subscribers when it changes.
import io.github.fenrur.signal.*
val count: Signal<Int> = signalOf(0)
// Read the current value
println(count.value) // 0
// Subscribe to changes
val unsubscribe = count.subscribe { result ->
result.fold(
onSuccess = { value -> println("Value: $value") },
onFailure = { error -> println("Error: $error") }
)
}
// Use as Kotlin property delegate
val currentCount by count
println(currentCount) // 0
// Unsubscribe when done
unsubscribe()
// Close the signal when no longer needed
count.close()A MutableSignal<T> extends Signal<T> with write capabilities.
val count = mutableSignalOf(0)
// Read and write
println(count.value) // 0
count.value = 5
println(count.value) // 5
// Atomic updates
count.update { it + 1 }
println(count.value) // 6
// Use as read-write property delegate
var delegatedCount by count
delegatedCount = 10
println(count.value) // 10A BindableSignal<T> is a read-only signal that acts as a proxy to another Signal. It allows you to dynamically switch the underlying signal at runtime while maintaining subscriptions.
val bindable = bindableSignalOf<Int>()
// Bind to a source signal
val source1 = signalOf(10)
bindable.bindTo(source1)
println(bindable.value) // 10
// Switch to a different source -- subscribers are automatically notified
val source2 = mutableSignalOf(100)
bindable.bindTo(source2)
println(bindable.value) // 100
source2.value = 200
println(bindable.value) // 200Factory Functions
// Create unbound (throws on value access until bound)
val unbound = bindableSignalOf<String>()
// Create with initial signal binding
val withSignal = bindableSignalOf(signalOf(42))
println(withSignal.value) // 42
// Create with initial value (creates internal signal automatically)
val withValue = bindableSignalOf(initialValue = "Hello")
println(withValue.value) // "Hello"
// Check binding state
println(withSignal.isBound()) // true
println(withSignal.currentSignal()) // returns the bound SignalOwnership Mode
When takeOwnership = true, the bindable signal takes ownership of bound signals and closes them automatically:
val bindable = bindableSignalOf<Int>(takeOwnership = true)
val source1 = signalOf(1)
bindable.bindTo(source1)
val source2 = signalOf(2)
bindable.bindTo(source2)
println(source1.isClosed) // true - automatically closed on rebind
bindable.close()
println(source2.isClosed) // true - closed when bindable closesCircular Binding Detection
The library automatically detects and prevents circular bindings that would cause infinite loops:
val a = bindableSignalOf(1)
val b = bindableSignalOf<Int>()
val c = bindableSignalOf<Int>()
b.bindTo(a)
c.bindTo(b)
// This would create a cycle: a -> b -> c -> a
a.bindTo(c) // Throws IllegalStateException: "Circular binding detected"
// You can check for cycles before binding
if (!BindableSignal.wouldCreateCycle(a, c)) {
a.bindTo(c)
}A BindableMutableSignal<T> acts as a proxy to another MutableSignal. Read and write operations are forwarded to the bound source.
val bindable = bindableMutableSignalOf<Int>()
val source = mutableSignalOf(10)
bindable.bindTo(source)
println(bindable.value) // 10
// Write through the bindable signal
bindable.value = 20
println(source.value) // 20 - changes propagate to sourceFactory Functions, Ownership Mode & Circular Binding Detection
Works the same as BindableSignal, but for MutableSignal sources:
// Create with initial value
val withValue = bindableMutableSignalOf(initialValue = "Hello")
// Create with initial mutable signal binding
val withSignal = bindableMutableSignalOf(mutableSignalOf(42))
// Ownership mode
val owned = bindableMutableSignalOf<Int>(takeOwnership = true)
// Circular detection
val a = bindableMutableSignalOf(1)
val b = bindableMutableSignalOf(2)
a.bindTo(b)
b.bindTo(a) // Throws IllegalStateException: "Circular binding detected"import io.github.fenrur.signal.operators.*| Operator | Description |
|---|---|
map { } |
Transform values |
bimap(forward, reverse) |
Bidirectional transform for MutableSignal |
mapToString() |
Convert to string representation |
mapNotNull { } |
Map and filter nulls |
scan(initial) { acc, value -> } |
Accumulate values over time |
runningReduce { acc, value -> } |
Accumulate starting from current value |
pairwise() |
Emit pairs of consecutive values (previous, current) |
flatten() |
Flatten Signal<Signal<T>> to Signal<T> |
flatMap { } / switchMap { } |
Map to signal and flatten |
val count = mutableSignalOf(1)
// Transform values
val doubled = count.map { it * 2 }
// Bidirectional transform (read and write through a lens)
val stringSignal = mutableSignalOf("42")
val intSignal = stringSignal.bimap(
forward = { it.toInt() },
reverse = { it.toString() }
)
println(intSignal.value) // 42
intSignal.value = 100
println(stringSignal.value) // "100"
// Accumulate
val sum = count.scan(0) { acc, value -> acc + value }
// Track changes
val changes = count.pairwise() // Pair(oldValue, newValue)| Operator | Description |
|---|---|
filter { } |
Filter values matching predicate |
filterNotNull() |
Filter out null values |
filterIsInstance<T>() |
Filter by type |
distinctUntilChangedBy { } |
Only emit when key changes |
distinctUntilChanged() |
No-op (signals are already distinct) |
val items = mutableSignalOf<Any?>(null)
val nonNull = items.filterNotNull()
val strings = items.filterIsInstance<String>()| Operator | Description |
|---|---|
combine(a, b) { } |
Combine 2-6 signals |
zip(other) |
Combine into Pair |
zip(b, c) |
Combine into Triple |
withLatestFrom(other) { } |
Combine with latest from other (only emits on source change) |
combineAll(...) |
Combine multiple signals into List |
List<Signal<T>>.combineAll() |
Extension for list of signals |
val a = mutableSignalOf(1)
val b = mutableSignalOf(2)
val c = mutableSignalOf(3)
// Combine with transform
val sum = combine(a, b, c) { x, y, z -> x + y + z }
// Combine into tuple
val pair = a.zip(b) // Signal<Pair<Int, Int>>
val triple = a.zip(b, c) // Signal<Triple<Int, Int, Int>>
// Sample latest
val sampled = a.withLatestFrom(b) { x, y -> x + y }| Operator | Description |
|---|---|
not() |
Negate |
and(other) |
Logical AND |
or(other) |
Logical OR |
xor(other) |
Logical XOR |
allOf(...) |
True if all signals are true |
anyOf(...) |
True if any signal is true |
noneOf(...) |
True if no signal is true |
val isLoading = mutableSignalOf(true)
val hasError = mutableSignalOf(false)
val isReady = isLoading.not().and(hasError.not())
val showSpinner = allOf(isLoading, hasError.not())| Operator | Description |
|---|---|
+, -, *, /, % |
Arithmetic operators |
coerceIn(min, max) |
Clamp to range |
coerceAtLeast(min) |
Ensure minimum |
coerceAtMost(max) |
Ensure maximum |
val a = mutableSignalOf(10)
val b = mutableSignalOf(3)
val sum = a + b // Signal<Int> = 13
val diff = a - b // Signal<Int> = 7
val product = a * b // Signal<Int> = 30
val quotient = a / b // Signal<Int> = 3
val remainder = a % b // Signal<Int> = 1
val clamped = a.coerceIn(mutableSignalOf(0), mutableSignalOf(5)) // 5| Operator | Description |
|---|---|
gt(other) |
Greater than |
lt(other) |
Less than |
eq(other) |
Equal |
neq(other) |
Not equal |
val age = mutableSignalOf(25)
val limit = mutableSignalOf(18)
val isAdult = age gt limit // Signal<Boolean> = true
val isMinor = age lt limit // Signal<Boolean> = false| Operator | Description |
|---|---|
+ |
Concatenate |
isEmpty() / isNotEmpty() |
Check empty |
isBlank() / isNotBlank() |
Check blank |
length() |
Get length |
trim() |
Trim whitespace |
uppercase() / lowercase() |
Case conversion |
val name = mutableSignalOf(" John ")
val trimmed = name.trim() // "John"
val upper = name.uppercase() // " JOHN "
val length = name.length() // 8
val valid = name.trim().isNotEmpty() // true| Operator | Description |
|---|---|
size() / isEmpty() / isNotEmpty() |
Size checks |
firstOrNull() / lastOrNull() / getOrNull(index) |
Element access |
contains(element) |
Membership check |
filterList { } / mapList { } / flatMapList { } |
Transform elements |
sorted() / sortedDescending() / sortedBy { } |
Sorting |
reversed() / take(n) / drop(n) / distinct() |
Slicing |
joinToString() |
Join to string |
val items = mutableSignalOf(listOf(3, 1, 4, 1, 5))
val count = items.size() // 5
val first = items.firstOrNull() // 3
val sorted = items.sorted() // [1, 1, 3, 4, 5]
val unique = items.distinct() // [3, 1, 4, 5]
val doubled = items.mapList { it * 2 } // [6, 2, 8, 2, 10]
val csv = items.joinToString(", ") // "3, 1, 4, 1, 5"| Operator | Description |
|---|---|
orDefault(value) / orDefault(signal) |
Default for null |
onEach { } / tap { } |
Side effect |
log(prefix) |
Log values |
isPresent() / isAbsent() |
Null checks |
val name = mutableSignalOf<String?>(null)
val displayName = name.orDefault("Anonymous")
val hasName = name.isPresent()
val debugged = name.log("Name changed")| Operator | Description |
|---|---|
toggle() |
Toggle boolean |
increment(by) / decrement(by) |
Increment/decrement number |
append(suffix) / prepend(prefix) / clear() |
String mutations |
add(element) / addAll(elements) / remove(element) / removeAt(index) / clearList() |
List mutations |
clearSet() |
Clear set |
put(key, value) / remove(key) / clearMap() |
Map mutations |
val isEnabled = mutableSignalOf(false)
isEnabled.toggle() // true
val count = mutableSignalOf(0)
count.increment() // 1
count.increment(5) // 6
count.decrement() // 5
val items = mutableSignalOf(listOf(1, 2, 3))
items.add(4) // [1, 2, 3, 4]
items.remove(2) // [1, 3, 4]
items.clearList() // []
val cache = mutableSignalOf(mapOf<String, Int>())
cache.put("a", 1) // {a=1}
cache.remove("a") // {}Group multiple signal updates together to defer notifications until the batch completes. This prevents intermediate states and reduces unnecessary recomputations.
val firstName = mutableSignalOf("John")
val lastName = mutableSignalOf("Doe")
val fullName = combine(firstName, lastName) { first, last -> "$first $last" }
val emissions = mutableListOf<String>()
fullName.subscribe { result ->
result.onSuccess { emissions.add(it) }
}
// emissions = ["John Doe"]
// Without batch: each update triggers a notification
firstName.value = "Jane" // emissions = ["John Doe", "Jane Doe"]
lastName.value = "Smith" // emissions = ["John Doe", "Jane Doe", "Jane Smith"]
// With batch: only one notification at the end
batch {
firstName.value = "Bob"
lastName.value = "Wilson"
}
// emissions = ["John Doe", "Jane Doe", "Jane Smith", "Bob Wilson"]
// Note: "Bob Doe" was never emitted!Nested Batches & Return Values
Batches can be nested. Only the outermost batch triggers notifications:
batch {
firstName.value = "Alice"
batch {
lastName.value = "Johnson"
// No notification yet
}
// Still no notification
}
// Now subscribers are notified with "Alice Johnson"batch { } returns the result of the block:
val result = batch {
firstName.value = "New"
lastName.value = "Name"
"Operation completed"
}
println(result) // "Operation completed"Use cases: form submissions, state resets, complex calculations, performance optimization of derived signals.
This library implements a push-pull model that guarantees glitch-free behavior:
- No intermediate states -- derived signals never observe inconsistent intermediate values
- Diamond pattern safety -- in dependency graphs like
c = combine(a.map{}, b.map{}), derived signals receive exactly one notification per source update - Consistent snapshots -- subscribers always see a consistent view of the signal graph
val source = mutableSignalOf(1)
val doubled = source.map { it * 2 }
val tripled = source.map { it * 3 }
val sum = combine(doubled, tripled) { d, t -> d + t }
val emissions = mutableListOf<Int>()
sum.subscribe { it.onSuccess { v -> emissions.add(v) } }
// emissions = [5] (2 + 3)
source.value = 2
// emissions = [5, 10] (4 + 6)
// Note: [5, 7] or [5, 8] never appears (no glitch)Convert between Signal and Kotlin Flow (all platforms):
// Add Coroutines as a dependency (not included transitively)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")// Signal -> Flow
val signal = mutableSignalOf(0)
val flow: Flow<Int> = signal.asFlow()
// StateFlow -> Signal (bidirectional sync)
val stateFlow: StateFlow<Int> = someStateFlow
val signal: Signal<Int> = stateFlow.asSignal(scope)
// MutableStateFlow -> MutableSignal (bidirectional sync)
val mutableStateFlow = MutableStateFlow(100)
val mutableSignal: MutableSignal<Int> = mutableStateFlow.asSignal(scope)
// Changes from either side are synchronized:
mutableSignal.value = 200 // Updates mutableStateFlow
mutableStateFlow.value = 300 // Notifies signal subscribers// Add Reactive Streams as a dependency (not included transitively)
implementation("org.reactivestreams:reactive-streams:1.0.4")// Signal -> Publisher
val publisher: Publisher<String> = signal.asReactiveStreamsPublisher()
// Publisher -> Signal
val signal: Signal<Int> = somePublisher.asSignal(initial = 0)// JDK Flow.Publisher -> Signal
val signal = jdkPublisher.asJdkPublisher(initial = 0)All signal implementations are thread-safe. Subscriptions, value reads, and value writes can be performed concurrently from multiple threads without additional synchronization.
Contributions are welcome! Please feel free to submit a Pull Request.
MIT License
Copyright (c) 2026 Livio TINNIRELLO
See LICENSE for full details.