Streams

A lightweight reactive streams library for Kotlin Multiplatform. Every stream always has a value.

Reactive state shouldn't be complicated

Reactive programming is a genuinely good idea: instead of manually updating every place that depends on some value, you declare the dependency and let changes propagate automatically. But many reactive libraries were designed for event streams and async sequences — problems where values arrive over time and might never arrive at all. For UI state, that model adds ceremony you don't need.

We needed reactive state for Kraft components. Flow was too heavy, and RxJS is a different universe. So we built the smallest thing that worked.

The core idea: streams always have a value

Most reactive libraries model streams as sequences that might eventually emit something. You subscribe, wait, and handle the "not yet" state with operators like startWith, defaultIfEmpty, or onErrorResumeNext.

Streams takes a simpler approach: every stream always has a current value. You can read it at any time with stream(). When you subscribe, you immediately get the current value.

This makes certain things easier. It also means you can't model "no value yet" without making T nullable. Trade-offs.
val source = StreamSource(42)

// Always has a value — read it anytime
println(source())  // 42

// Subscribe — immediately called with current value
source.subscribeToStream { value ->
    println("Got: $value")
}
// prints "Got: 42" immediately

// Update — all subscribers notified
source(99)
// prints "Got: 99"

This works well for UI programming. In Kraft, components subscribe to streams and re-render when values change. Because streams always have a value, the first render always has data.

Two types

The entire library is built on two types:

  • Stream<T> — read-only. Has invoke(): T (get value) and subscribeToStream() (observe changes).
  • StreamSource<T> — read-write. Adds invoke(next: T) (set value), modify(), and reset().

Operators transform a Stream into another Stream. The chain is lazy — operators only subscribe upstream when they have downstream subscribers.

What you get

Always Has a Value

stream() never blocks, never returns null (unless T is nullable).

Composable Operators

map, filter, combine, debounce, fold, distinct, history, and more.

Lazy Subscriptions

Operators only subscribe upstream when they have downstream subscribers.

Kotlin Multiplatform

Core + all operators work on JVM and JS. Plus JS-specific extras like animTicker() and localStorage persistence.

Resilient

A failing subscriber never prevents other subscribers from being notified. Streams keep flowing.

Flow Interop

stream.asFlow() bridges into Kotlin's Flow world when you need it.

Quick taste

val name = StreamSource("World")
val greeting = name.map { "Hello, $it!" }

greeting.subscribeToStream { println(it) }
// prints "Hello, World!"

name("Kotlin")
// prints "Hello, Kotlin!"

// Combine two streams
val firstName = StreamSource("Jane")
val lastName = StreamSource("Doe")

val fullName = firstName.pairedWith(lastName).map { (f, l) -> "$f $l" }
println(fullName())  // "Jane Doe"

firstName("John")
println(fullName())  // "John Doe"