Documentation

@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.

Counter.wh
@store
object Counter {
  var count = $state(0)
}
Kotlin waiting
Scroll into view to compile.

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

@storeScreen state
LifetimeApp lifetimeScreen/navigation lifetime
ScopeGlobalPer-screen
Use CaseAuth, cart, settingsScreen-specific state
Survives rotationYesYes
Survives navigationYesNo

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>
}

See Also

  • @prop - Component properties
  • $data - Screen data loading