Signal

A lightweight, glitch-free reactive signal library for Kotlin Multiplatform, inspired by SolidJS signals and Kotlin StateFlow.
Platforms
Quick Example
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") } }
count.value = 5
Features
Table of Contents
Installation
Kotlin Multiplatform
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.fenrur:signal:3.0.1")
}
}
}
}
JVM / Android only
dependencies {
implementation("io.github.fenrur:signal-jvm:3.0.1")
}
Maven
<dependency>
<groupId>io.github.fenrur</groupId>
<artifactId>signal-jvm</artifactId>
<version>3.0.1</version>
</dependency>
Core Concepts
Signal
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)
println(count.value)
val unsubscribe = count.subscribe { result ->
result.fold(
onSuccess = { value -> println() },
onFailure = { error -> println() }
)
}
currentCount count
println(currentCount)
unsubscribe()
count.close()
MutableSignal
A MutableSignal<T> extends Signal<T> with write capabilities.
val count = mutableSignalOf(0)
println(count.value)
count.value = 5
println(count.value)
count.update { it + 1 }
println(count.value)
var delegatedCount by count
delegatedCount = 10
println(count.value)
BindableSignal
A 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>()
val source1 = signalOf(10)
bindable.bindTo(source1)
println(bindable.value)
val source2 = mutableSignalOf(100)
bindable.bindTo(source2)
println(bindable.value)
source2.value = 200
println(bindable.value)
Factory Functions
val unbound = bindableSignalOf<String>()
val withSignal = bindableSignalOf(signalOf(42))
println(withSignal.value)
withValue = bindableSignalOf(initialValue = )
println(withValue.value)
println(withSignal.isBound())
println(withSignal.currentSignal())
Ownership Mode
When takeOwnership = true, the bindable signal takes ownership of bound signals and closes them automatically:
val bindable = bindableSignalOf<Int>(takeOwnership = true)
source1 = signalOf()
bindable.bindTo(source1)
source2 = signalOf()
bindable.bindTo(source2)
println(source1.isClosed)
bindable.close()
println(source2.isClosed)
Circular 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)
a.bindTo(c)
(!BindableSignal.wouldCreateCycle(a, c)) {
a.bindTo(c)
}
BindableMutableSignal
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)
bindable.value = 20
println(source.value)
Operators
import io.github.fenrur.signal.operators.*
Transformation
val count = mutableSignalOf(1)
val doubled = count.map { it * 2 }
val stringSignal = mutableSignalOf("42")
val intSignal = stringSignal.bimap(
forward = { it.toInt() },
reverse = { it.toString() }
)
println(intSignal.value)
intSignal.value =
println(stringSignal.value)
sum = count.scan() { acc, value -> acc + value }
changes = count.pairwise()
Filtering
val items = mutableSignalOf<Any?>(null)
val nonNull = items.filterNotNull()
val strings = items.filterIsInstance<String>()
Combination
val a = mutableSignalOf(1)
val b = mutableSignalOf(2)
val c = mutableSignalOf(3)
val sum = combine(a, b, c) { x, y, z -> x + y + z }
val pair = a.zip(b)
val triple = a.zip(b, c)
val sampled = a.withLatestFrom(b) { x, y -> x + y }
Boolean
val isLoading = mutableSignalOf(true)
val hasError = mutableSignalOf(false)
val isReady = isLoading.not().and(hasError.not())
val showSpinner = allOf(isLoading, hasError.not())
Numeric
val a = mutableSignalOf(10)
val b = mutableSignalOf(3)
val sum = a + b
val diff = a - b
val product = a * b
val quotient = a / b
val remainder = a % b
clamped = a.coerceIn(mutableSignalOf(), mutableSignalOf())
Comparison
| Operator | Description |
|---|
gt(other) |
val age = mutableSignalOf(25)
val limit = mutableSignalOf(18)
val isAdult = age gt limit
val isMinor = age lt limit
String
val name = mutableSignalOf(" John ")
val trimmed = name.trim()
val upper = name.uppercase()
val length = name.length()
val valid = name.trim().isNotEmpty()
Collection (List)
val items = mutableSignalOf(listOf(3, 1, 4, 1, 5))
val count = items.size()
val first = items.firstOrNull()
sorted = items.sorted()
unique = items.distinct()
doubled = items.mapList { it * }
csv = items.joinToString()
Utility
val name = mutableSignalOf<String?>(null)
val displayName = name.orDefault("Anonymous")
val hasName = name.isPresent()
val debugged = name.log("Name changed")
MutableSignal Modifiers
val isEnabled = mutableSignalOf()
isEnabled.toggle()
count = mutableSignalOf()
count.increment()
count.increment()
count.decrement()
items = mutableSignalOf(listOf(, , ))
items.add()
items.remove()
items.clearList()
cache = mutableSignalOf(mapOf<String, >())
cache.put(, )
cache.remove()
Batching
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")
fullName = combine(firstName, lastName) { first, last -> }
emissions = mutableListOf<String>()
fullName.subscribe { result ->
result.onSuccess { emissions.add(it) }
}
firstName.value =
lastName.value =
batch {
firstName.value =
lastName.value =
}
Nested Batches & Return Values
Batches can be nested. Only the outermost batch triggers notifications:
Use cases: form submissions, state resets, complex calculations, performance optimization of derived signals.
Glitch-Free Semantics
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) } }
source.value = 2
Integrations
Kotlin Coroutines Flow
Convert between Signal and Kotlin Flow (all platforms):
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
val signal = mutableSignalOf(0)
val flow: Flow<Int> = signal.asFlow()
stateFlow: StateFlow<> = someStateFlow
signal: Signal<> = stateFlow.asSignal(scope)
mutableStateFlow = MutableStateFlow()
mutableSignal: MutableSignal<> = mutableStateFlow.asSignal(scope)
mutableSignal.value =
mutableStateFlow.value =
Reactive Streams (JVM only)
implementation("org.reactivestreams:reactive-streams:1.0.4")
val publisher: Publisher<String> = signal.asReactiveStreamsPublisher()
val signal: Signal<Int> = somePublisher.asSignal(initial = 0)
Java Flow (JDK 9+, JVM only)
val signal = jdkPublisher.asJdkPublisher(initial = 0)
Thread Safety
All signal implementations are thread-safe. Subscriptions, value reads, and value writes can be performed concurrently from multiple threads without additional synchronization.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
MIT License
Copyright (c) 2026 Livio TINNIRELLO
See LICENSE for full details.