State Management
Three mechanisms, each for a different job.
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 |