@store
@store object holds state for the life of the app, readable and writable
from any screen.
Basic Usage
// src/lib/stores/AppSettings.wh
@store object AppSettings {
var darkMode = $state(false)
var language = $state("en")
}
// Use anywhere in your app
<Switch
bind:checked={AppSettings.darkMode}
label="Dark Mode"
/> How It Works
A @store object is one instance for the life of the app, not tied to any
screen's lifecycle. Each $state field is its own state — reading it redraws
only what reads that field, the same as a $state declared anywhere else.
The same read works outside a screen too: from a worker, a receiver, a $dispatch.
See it happen
Start with a field, add a function, then a suspend one — the object stays a plain Kotlin object the whole way.
@store
object Counter {
var count = $state(0)
}A $state field inside @store object becomes its own snapshot state holder — reading count anywhere subscribes to that one field, and writing it recomposes only what read it.
Authentication Store
// src/lib/stores/Auth.wh
@store object Auth {
var token: String? = $state(null)
var user: User? = $state(null)
var isLoggedIn = $state(false)
suspend fun login(email: String, password: String) {
val response = $fetch.post(
url = "https://api.example.com/login",
body = { email: email, password: password }
)
token = response.token
user = response.user
isLoggedIn = true
}
fun logout() {
token = null
user = null
isLoggedIn = false
}
}
// Use in any screen
@if (Auth.isLoggedIn) {
<Text>Welcome, {Auth.user?.name}!</Text>
<Button onClick={Auth.logout}>Logout</Button>
} else {
<Button onClick={() => $navigate("/login")}>Login</Button>
} Cart Store
@store object Cart {
var items: List<CartItem> = $state(listOf())
val total: Double get() = items.sumOf { it.price * it.quantity }
val itemCount: Int get() = items.sumOf { it.quantity }
fun add(product: Product) {
val existing = items.find { it.id == product.id }
if (existing != null) {
items = items.map { if (it.id == product.id) it.copy(quantity = it.quantity + 1) else it }
} else {
items = items + CartItem(product.id, product.name, product.price, 1)
}
}
fun remove(productId: String) {
items = items.filter { it.id != productId }
}
fun clear() {
items = listOf()
}
}
// Use in product list
<Button onClick={() => Cart.add(product)}>Add to Cart</Button>
// Use in cart screen
<Column>
<Text>Items: {Cart.itemCount}</Text>
<Text>Total: ${Cart.total}</Text>
<Button onClick={Cart.clear}>Clear Cart</Button>
</Column> Derived State
Use get() for computed properties:
@store object UserPrefs {
var firstName = $state("")
var lastName = $state("")
val fullName: String get() = "$firstName $lastName"
val initials: String get() = "${firstName.firstOrNull() ?: ""}${lastName.firstOrNull() ?: ""}"
}
<Text>{UserPrefs.fullName}</Text>
<Text>{UserPrefs.initials}</Text> Persistence
A plain @store does not persist to disk. Add (persist) and its $state fields are written on every change and loaded before the first read:
@store(persist) object Settings {
var darkMode = $state(false)
var notifications = $state(true)
} The types that persist are the ones that cross $fetch's JSON boundary:
primitives, String, enums, lists, and data classes of those. A
field the boundary cannot carry — a lambda, an Any — is a build error naming
the field. A $state([…]) list is changed in place and never assigned, so it
cannot be persisted; hold a list value with $state(listOf(…)) and assign it
instead.
Store vs a screen's own state
| @store | Screen state | |
|---|---|---|
| Lifetime | App lifetime | Screen/navigation lifetime |
| Scope | Global | Per-screen |
| Use Case | Auth, cart, settings | Screen-specific state |
| Survives rotation | Yes | Yes |
| Survives navigation | Yes | No |
Multiple Stores
// src/lib/stores/Auth.wh
@store object Auth { ... }
// src/lib/stores/Cart.wh
@store object Cart { ... }
// src/lib/stores/Theme.wh
@store object Theme { ... }
// Use together
@if (Auth.isLoggedIn) {
<Text>Cart: {Cart.itemCount} items</Text>
}
<Switch
bind:checked={Theme.darkMode}
label="Dark Mode"
/> Case Insensitive
@store object Settings { }
@Store object Settings { }
@STORE object Settings { } Stores are singletons with app-wide lifetime. Don't store screen-specific state here - use screen state or local state instead.
Complete Example
// src/lib/stores/Player.wh
@store object Player {
var currentTrack: Track? = $state(null)
var isPlaying = $state(false)
var position: Int = $state(0)
var duration: Int = $state(0)
val progress: Float get() =
if (duration > 0) position.toFloat() / duration else 0f
fun play(track: Track) {
currentTrack = track
isPlaying = true
position = 0
// Start playback
}
fun pause() {
isPlaying = false
}
fun resume() {
isPlaying = true
}
fun seek(pos: Int) {
position = pos
}
}
// Mini player (shown on all screens)
@if (Player.currentTrack != null) {
<Card onClick={() => $navigate("/player")}>
<Row class="gap-12">
<Image src={Player.currentTrack.artwork} w={48} h={48} />
<Column>
<Text>{Player.currentTrack.title}</Text>
<Text>{Player.currentTrack.artist}</Text>
</Column>
<Button
icon={Player.isPlaying ? "pause" : "play"}
onClick={() =>
Player.isPlaying ? Player.pause() : Player.resume()
}
/>
</Row>
</Card>
}