State Management

Three mechanisms, each for a different job.

We started with just value(). Then we needed debouncing, so we added streams. Then we needed shared state, so we added subscriptions. Three mechanisms is more than we planned — but each one solves a distinct problem.

1. Local state with value()

The simplest and most common pattern. Declare a property with value(), change it, and the component redraws:

class Counter(ctx: NoProps) : PureComponent(ctx) {
    private var count by value(0)

    override fun VDom.render() {
        div { +"Count: $count" }
        ui.button {
            onClick { count++ }  // This triggers a redraw
            +"Increment"
        }
    }
}

value() uses Kotlin's delegated properties. Under the hood, the setter calls triggerRedraw() — you don't need to think about it.

With change callbacks

React to state changes:

private var query by value("") { newValue ->
    console.log("Query changed to: $newValue")
    search(newValue)
}

Complex state

Use a data class when you have related state:

data class State(
    val name: String = "",
    val email: String = "",
    val agreed: Boolean = false,
)

private var state by value(State())

// Update one field — immutable copy, automatic redraw
onClick { state = state.copy(agreed = true) }

2. Stream-backed state

For state that should be debounced, throttled, or transformed before triggering a redraw:

class SearchBox(ctx: NoProps) : PureComponent(ctx) {

    // Debounce input by 300ms before searching
    private var query by stream("") {
        it.debounce(300.milliseconds)
    } handler { debouncedQuery ->
        performSearch(debouncedQuery)
    }

    override fun VDom.render() {
        ui.input {
            input {
                value = query
                onInput { query = it.target.asDynamic().value as String }
            }
        }
    }
}

The stream config lambda lets you apply operators from the Ultra streams library — debounce, throttle, map, filter, and more.

3. Subscribing to external streams

When state lives outside the component — global state, shared data, tickers:

class LiveDashboard(ctx: NoProps) : PureComponent(ctx) {

    // Subscribe to a global auth state
    private val auth by subscribingTo(AppState.auth)

    // Subscribe to a ticker that emits every second
    private val tick by subscribingTo(ticker(1.seconds))

    override fun VDom.render() {
        div { +"Logged in as: ${auth.user?.name}" }
        div { +"Uptime: ${tick.count}s" }
    }
}

Key behaviors:

  • The component redraws whenever the stream emits a new value
  • Subscriptions auto-unsubscribe when the component unmounts — no memory leaks
  • You can subscribe to any Stream<T> from the Ultra streams library

Reactive transformations

Combine streams with operators:

// A sine wave that updates every 100ms
private val wave by subscribingTo(
    ticker(100.milliseconds).map { tick ->
        sin(tick.count * 10 * PI / 180.0)
    }
)

Persisting state in local storage

Kraft integrates with browser local storage for state that should survive page reloads:

@Serializable
data class UserPreferences(val theme: String = "dark", val lang: String = "en")

class SettingsPage(ctx: NoProps) : PureComponent(ctx) {

    // This state persists in localStorage under key "user-prefs"
    private val prefs = StreamSource(UserPreferences())
        .persistInLocalStorage("user-prefs", UserPreferences.serializer())

    private val currentPrefs by subscribingTo(prefs)

    override fun VDom.render() {
        div { +"Theme: ${currentPrefs.theme}" }

        ui.button {
            onClick {
                prefs(currentPrefs.copy(theme = "light"))
            }
            +"Switch to light"
        }
    }
}

The value is serialized to JSON and stored in localStorage. On next page load, it's restored automatically.

Async data loading

For loading async data with loading/error/loaded states, Kraft provides the dataLoader pattern. Here's a quick example:

private val loader = dataLoader {
    flow {
        val user = api.getUser(props.userId)
        emit(user)
    }
}

override fun VDom.render() {
    loader(this) {
        loading { ui.active.loader { } }
        error { err -> ui.negative.message { +"Failed: ${err.message}" } }
        loaded { user -> ui.header { +user.name } }
    }
}

This covers the basics — reloading, silent refresh, state inspection, and more are documented in the dedicated Data Loading page.

Patterns summary

What you need Use this Example
Simple local state var x by value(0) Counter, toggles
Debounced/throttled state stream("") { it.debounce(300.ms) } Search input
Global/shared state val x by subscribingTo(stream) Auth, preferences
Persistent state StreamSource().persistInLocalStorage() User settings
Async data dataLoader { flow { emit(data) } } API calls

See it in action