Skip to main content
Version: 4.2

Lifecycle and State

This page explains how objects injected with Koin behave in the Compose lifecycle: what happens on recomposition, what each Koin API releases when a Composable leaves the composition, and where to place injection calls. It assumes you know koinInject() and koinViewModel() (see Koin for Compose) and the Compose lifecycle.

Recomposition​

koinInject() resolves the dependency once and keeps it with remember, keyed on the qualifier, the scope and the parameters:

@Composable
fun UserScreen(userId: String) {
val repository = koinInject<UserRepository>() // resolved once
val presenter = koinInject<UserPresenter> { parametersOf(userId) } // resolved again when userId changes
}

On a recomposition with the same keys, you get the same object. When a key changes (a new userId), Koin resolves again. For a single, that returns the same instance. For a factory, that creates a new one.

koinViewModel() does not use remember: the ViewModel is stored in the ViewModelStoreOwner and survives recomposition and Android configuration changes. Its parameters are used only at creation (see ViewModel in Compose).

Both are Composable functions, so they can't be called inside remember { }, LaunchedEffect or an onClick lambda. Call them in the Composable body and use the result inside the lambda:

@Composable
fun SaveButton() {
val repository = koinInject<UserRepository>()

Button(onClick = { repository.save() }) {
Text("Save")
}
}

What is released, and when​

APIOn recompositionWhen the Composable leaves the composition
koinInject()Same object if keys are unchangedReference dropped. Koin keeps a single or scoped instance
koinViewModel()Same ViewModelNothing. The ViewModel is cleared with its ViewModelStoreOwner
KoinApplicationSame Koin instanceKoin keeps running. Stopped only if the composition is abandoned
KoinScope, rememberKoinScopeSame scopeScope closed
KoinNavigationScopeSame scopeScope kept. Closed only if the composition is abandoned
UnboundKoinScopeSame scopeNothing. You close the scope
rememberKoinModulesModules loaded onceModules unloaded only if you set unloadOnForgotten

Details: Scopes in Compose, Dynamic Modules.

Where to inject​

Inject at the screen level and pass plain data and lambdas down. Child Composables then don't depend on Koin, and previews and tests don't need a Koin instance:

@Composable
fun UserListScreen(viewModel: UserListViewModel = koinViewModel()) {
val users by viewModel.users.collectAsState()
UserList(users = users, onDelete = viewModel::delete)
}

@Composable
fun UserList(users: List<User>, onDelete: (User) -> Unit) {
LazyColumn {
items(users) { user -> UserCard(user, onDelete) }
}
}

Avoid koinInject() inside LazyColumn items: each item runs its own resolution and remember. Inject once in the parent and pass the object or a lambda.

Pass as a parameter or inject?​

Pass as a parameterInject with Koin
Values that change: ids, search query, selectionLong-lived services: repositories, use cases
Navigation argumentsInfrastructure: database, HTTP client
Data the parent already hasViewModels
@Composable
fun UserProfile(
userId: String, // changes: pass it
repository: UserRepository = koinInject() // service: inject it
) {
var user by remember { mutableStateOf<User?>(null) }

LaunchedEffect(userId) {
user = repository.getUser(userId)
}

user?.let { ProfileContent(it) }
}

Injected objects and stability​

Koin does not change how Compose sees a type. A Composable parameter of type UserRepository or UserViewModel is stable or unstable according to the Compose compiler rules, like any other class. If skipping matters for a Composable, pass it stable values (state, primitives, lambdas) instead of the injected object.

Side effects​

Injected objects are regular Kotlin objects: use them in LaunchedEffect, DisposableEffect or SideEffect like any other value. Resolve them in the Composable body first:

@Composable
fun EventScreen() {
val eventBus = koinInject<EventBus>()

DisposableEffect(eventBus) {
val listener = eventBus.subscribe { event -> /* ... */ }
onDispose { eventBus.unsubscribe(listener) }
}
}