Note
If you have more practical use cases, please let me know or create a pull request
A Kotlin Multiplatform library providing enhanced ViewModel utilities for Compose Multiplatform.
- Android
- iOS
- Desktop (JVM)
- Web (JS/Wasm)
- LaunchedEffectOnce: Execute side effects only once per page lifecycle
- DisposableEffectOnce: Execute side effects with cleanup, both only once per page lifecycle
- EventEffect: Lifecycle-aware one-time event handling from ViewModel to UI
- StateBus: Cross-screen state sharing with automatic lifecycle management
- SharedViewModel: Share ViewModels across multiple screens with automatic cleanup
Add the JitPack repository to your settings.gradle.kts:
dependencyResolutionManagement {
repositories {
// ...
maven("https://jitpack.io")
}
}Add the dependency to your module's build.gradle.kts:
dependencies {
implementation("com.github.OCNYang:ViewModelInCompose:<version>")
}Replace
<version>with the latest version shown in the badge above, or check releases.
A LaunchedEffect that executes only once per page lifecycle, surviving recomposition and configuration changes.When jumping to the next page and returning, it will not be re-executed. It will only be executed when reopening after exiting the current page.
- One-time initialization (data loading, analytics tracking)
- Side effects that should not repeat on recomposition or screen rotation
| Behavior | LaunchedEffect | LaunchedEffectOnce |
|---|---|---|
| Recomposition | May re-execute | Does not re-execute |
| When returning from the next page | Re-execute | Does not re-execute |
| Configuration change | Re-executes | Does not re-execute |
| Page recreation | Re-executes | Re-executes |
@Composable
fun MyScreen() {
// Executes only once per page lifecycle
LaunchedEffectOnce {
viewModel.loadData()
analytics.trackPageView()
}
}@Composable
fun UserScreen(userId: String) {
// Re-executes only when userId changes
LaunchedEffectOnce(userId) {
viewModel.loadUser(userId)
}
}The following diagrams show when each effect type executes during the Composable lifecycle:
flowchart LR
A[First Composition] --> B[State Recomposition] --> C[Key Changed] --> D[Navigate Away] --> E[Return] --> F[Config Change] --> G[Page Exit]
A -.- A1[✅ effect executes]
B -.- B1[❌ skipped]
C -.- C1[✅ effect executes]
D -.- D1[cancelled]
E -.- E1[✅ effect executes]
F -.- F1[✅ effect executes]
G -.- G1[cancelled]
flowchart LR
A[First Composition] --> B[State Recomposition] --> C[Key Changed] --> D[Navigate Away] --> E[Return] --> F[Config Change] --> G[Page Exit]
A -.- A1[✅ effect executes]
B -.- B1[❌ skipped]
C -.- C1[✅ effect executes]
D -.- D1[paused]
E -.- E1[❌ skipped]
F -.- F1[❌ skipped]
G -.- G1[ViewModel cleared]
flowchart LR
A[First Composition] --> B[State Recomposition] --> C[Key Changed] --> D[Navigate Away] --> E[Return] --> F[Config Change] --> G[Page Exit]
A -.- A1[✅ effect executes]
B -.- B1[❌ skipped]
C -.- C1[✅ onDispose + effect]
D -.- D1[✅ onDispose]
E -.- E1[✅ effect executes]
F -.- F1[✅ onDispose + effect]
G -.- G1[✅ onDispose]
flowchart LR
A[First Composition] --> B[State Recomposition] --> C[Key Changed] --> D[Navigate Away] --> E[Return] --> F[Config Change] --> G[Page Exit]
A -.- A1[✅ effect executes]
B -.- B1[❌ skipped]
C -.- C1[- no key]
D -.- D1[paused]
E -.- E1[❌ skipped]
F -.- F1[❌ skipped]
G -.- G1[✅ onDispose]
| Effect | First Composition | State Recomposition | Key Changed | Navigate Away | Return | Config Change | Page Exit |
|---|---|---|---|---|---|---|---|
| LaunchedEffect | ✅ execute | ❌ skip | ✅ execute | cancel | ✅ execute | ✅ execute | cancel |
| LaunchedEffectOnce | ✅ execute | ❌ skip | ✅ execute | pause | ❌ skip | ❌ skip | - |
| DisposableEffect | ✅ execute | ❌ skip | ✅ dispose+execute | ✅ dispose | ✅ execute | ✅ dispose+execute | ✅ dispose |
| DisposableEffectOnce | ✅ execute | ❌ skip | - (no key) | pause | ❌ skip | ❌ skip | ✅ dispose |
A DisposableEffect that executes only once per page lifecycle. Both the effect and the onDispose callback execute only once - effect runs on first composition, onDispose runs when the page is destroyed (ViewModel cleared).
- Subscribing to resources that need cleanup (listeners, observers)
- Acquiring resources on screen enter and releasing on screen exit
- One-time setup with guaranteed cleanup
| Behavior | DisposableEffect | DisposableEffectOnce |
|---|---|---|
| Effect execution | On every key change | Only once |
| onDispose trigger | On key change or leave composition | Only when page is destroyed |
| Configuration change | Re-executes effect + onDispose | Neither re-executes |
| Recomposition | May re-execute | Does not re-execute |
@Composable
fun MyScreen() {
DisposableEffectOnce {
// Effect code runs once when page is created
val listener = registerListener()
onDispose {
// Cleanup runs once when page is destroyed
listener.unregister()
}
}
}@Composable
fun SensorScreen() {
DisposableEffectOnce {
// Subscribe to sensor updates - runs once
sensorManager.registerListener(sensorListener)
onDispose {
// Unsubscribe - runs once when leaving the screen
sensorManager.unregisterListener(sensorListener)
}
}
}Lifecycle-aware event collection for handling one-time UI events from ViewModel.
- Navigation events
- Showing toasts or snackbars
- One-time UI actions triggered by ViewModel
- Lifecycle-aware: only collects events when UI is visible
- Events are guaranteed to be delivered (won't be lost during config changes)
- Each event is consumed exactly once
class MyViewModel : ViewModel() {
private val _events = EventChannel<UiEvent>()
val events: Flow<UiEvent> = _events.flow
fun onSubmit() {
viewModelScope.launch {
_events.send(UiEvent.ShowToast("Success!"))
_events.send(UiEvent.NavigateBack)
}
}
}
sealed interface UiEvent {
data class ShowToast(val message: String) : UiEvent
data object NavigateBack : UiEvent
}@Composable
fun MyScreen(viewModel: MyViewModel) {
EventEffect(viewModel.events) { event ->
when (event) {
is UiEvent.ShowToast -> showToast(event.message)
is UiEvent.NavigateBack -> navigator.navigateBack()
}
}
}Cross-screen state sharing solution with automatic lifecycle management.
- Pass data between screens (e.g., selection result from screen B back to screen A)
- Share temporary state across multiple screens
- Need automatic cleanup when no screens are observing
- Automatic Listener Tracking: Tracks observer count automatically
- Automatic Cleanup: State is cleared when no observers remain
- Thread-Safe: Uses synchronized locks and atomic counters
- Configuration Change Survival: State persists across screen rotation
- Process Death Recovery: Android platform supports state restoration via SavedStateHandle
- Kotlin Multiplatform: Works on all supported platforms
@Composable
fun App() {
ProvideStateBus {
MyNavHost()
}
}@Composable
fun ScreenB() {
val stateBus = LocalStateBus.current
Button(onClick = {
stateBus.setState<Person>(selectedPerson)
navigator.navigateBack()
}) {
Text("Confirm Selection")
}
}@Composable
fun ScreenA() {
val stateBus = LocalStateBus.current
val person = stateBus.observeState<Person?>()
Text("Selected: ${person?.name ?: "None"}")
}When using generic types that may conflict (e.g., Result<String> vs Result<Int>), specify a custom key:
// Use explicit keys to avoid type erasure conflicts
val userResult = stateBus.observeState<Result<User>?>(stateKey = "userResult")
val orderResult = stateBus.observeState<Result<Order>?>(stateKey = "orderResult")| API | Description |
|---|---|
ProvideStateBus |
Provides StateBus to the composition tree |
LocalStateBus.current |
Gets the current StateBus instance |
observeState<T>() |
Observes state with automatic listener tracking |
setState<T>() |
Sets state value |
removeState<T>() |
Manually removes state |
hasState() |
Checks if state exists |
Activity/Fragment ViewModelStore
└─ StateBus (ViewModel)
└─ stateDataMap (thread-safe state cache)
└─ persistence (platform-specific, SavedStateHandle on Android)
NavBackStackEntry (per screen)
└─ StateBusListenerViewModel
└─ Registers/unregisters listener on lifecycle
| Platform | Process Death Recovery |
|---|---|
| Android | ✅ Supported (SavedStateHandle) |
| iOS | ❌ Not supported |
| Desktop | ❌ Not supported |
| Web | ❌ Not supported |
Share ViewModels across multiple screens with automatic cleanup when all sharing screens are removed from the navigation stack.
- Multiple screens need to share the same ViewModel instance
- Data should persist while navigating between related screens
- ViewModel should be cleared when leaving the entire flow
| Feature | SharedViewModel | StateBus |
|---|---|---|
| Purpose | Share ViewModel instances | Share state values |
| Scope | Defined by SharedScope |
Global within StateBus |
| Cleanup | When all includedRoutes leave stack |
When no observers remain |
| Use Case | Complex business logic sharing | Simple data passing |
object OrderFlowScope : SharedScope(
includedRoutes = setOf(Route.Cart::class, Route.Checkout::class, Route.Payment::class)
)@Composable
fun App() {
ProvideSharedViewModelRegistry {
MyNavHost()
}
}@Composable
fun MyNavHost(navController: NavHostController) {
val backStack by navController.currentBackStack.collectAsState()
val routesInStack = remember(backStack) {
backStack.mapNotNull { entry ->
entry.destination.route?.let { getRouteClass(it) }
}.toSet()
}
RegisterSharedScope(routesInStack, OrderFlowScope)
NavHost(navController, startDestination = Route.Cart) {
composable<Route.Cart> { CartScreen() }
composable<Route.Checkout> { CheckoutScreen() }
composable<Route.Payment> { PaymentScreen() }
}
}@Composable
fun CartScreen() {
val orderVm = sharedViewModel<OrderFlowScope, OrderViewModel> {
OrderViewModel()
}
}
@Composable
fun CheckoutScreen() {
// Same ViewModel instance as CartScreen
val orderVm = sharedViewModel<OrderFlowScope, OrderViewModel> {
OrderViewModel()
}
}object OrderFlowScope : SharedScope(
includedRoutes = setOf(Route.Cart::class, Route.Checkout::class, Route.Payment::class)
)// IMPORTANT: Use PARENT route, not nested routes
object DashboardScope : SharedScope(
includedRoutes = setOf(Route.Dashboard::class) // Parent route only!
)Apache License 2.0