ViewModel
PulseViewModel is the core building block of PulseDesktop. It manages the UI state for a single content of your app, the part of a screen one ViewModel answers for.
Creating a ViewModel
Extend PulseViewModel with your five type parameters:
class MyViewModel : PulseViewModel<MyState, MyAction, MyEvent, MyBroadcast, MyUnicast>(
initialUiState = MyState(),
) {
override fun onSetup() { /* start coroutines here */ }
override fun onAction(uiAction: MyAction) { /* handle user intents */ }
override fun onReceive(broadcast: MyBroadcast) { /* react to broadcasts */ }
}Lifecycle Hooks
onSetup()
Called once, by PulseContent, the first time it observes the ViewModel. Use this to start long-running coroutines such as repository flows.
TIP
The lifetime follows the ViewModelStoreOwner holding the ViewModel, not the composition. A composition restart, another destination covering the route, or a refresh of the subtree all keep the state, and none of them repeats onSetup(). The scope is cancelled when the owner is cleared.
For tying a ViewModel to a single destination, see Navigation 3.
override fun onSetup() {
coroutineScope.launch {
repository.data.collect { data ->
update { copy(items = data) }
}
}
}onAction(uiAction)
Called each time the UI dispatches an action. Keep this non-blocking — launch coroutines for async work:
override fun onAction(uiAction: MyAction) {
coroutineScope.launch {
when (uiAction) {
MyAction.Load -> loadData()
is MyAction.Select -> selectItem(uiAction.id)
}
}
}onReceive(broadcast)
Called when the parent Container delivers a broadcast. You can update state or emit events:
override fun onReceive(broadcast: MyBroadcast) {
when (broadcast) {
MyBroadcast.Refresh -> update { copy(isRefreshing = true) }
is MyBroadcast.UserChanged -> update { copy(userId = broadcast.id) }
}
}Updating State
Use update { } to produce the next immutable state. The lambda receives the current state as this:
update { copy(count = count + 1, isLoading = false) }Emitting Events
Use event() to send a one-time side effect to the UI:
event(MyEvent.NavigateTo(Screen.Detail))
event(MyEvent.ShowError("Something went wrong"))Accessing Current State
Read the latest state snapshot synchronously via currentState:
override fun onAction(uiAction: MyAction) {
if (currentState.isLoading) return // guard check
coroutineScope.launch { /* ... */ }
}Driving the lifecycle yourself
PulseContent runs onSetup() for you, whichever way the ViewModel was built. What changes is teardown. close() is called by onCleared(), and only a ViewModelStore calls that.
Hold a ViewModel in a plain remember and nothing ever clears it, so cancel it yourself. A Container needs the same treatment.
WARNING
The ViewModel then lives exactly as long as this composition. Leaving and re-entering it builds a new instance, and its state is lost. Add navigation when that matters.
val viewModel = remember { CounterViewModel(repository) }
DisposableEffect(viewModel) {
onDispose { viewModel.cancel() }
}
PulseContent(viewModel = viewModel) { state, onAction ->
// Compose UI
}val container = remember { CounterContainer(viewModels = listOf(viewModel)) }
DisposableEffect(container) {
onDispose { container.close() }
}