Skip to main content
Version: 4.2 - LTS

Lifecycle & State in Compose

This guide covers how Koin integrates with Compose's lifecycle and state management. Understanding these concepts helps you write efficient, bug-free Compose applications.

Compose Lifecycle Overview​

A Composable has three lifecycle events:

  1. Enter Composition - Composable is first called
  2. Recomposition - Composable re-executes when state changes (0 or more times)
  3. Leave Composition - Composable is removed from the tree

Koin's Compose APIs are designed to work efficiently with this lifecycle.

Injection and Recomposition​

How koinInject() Works​

koinInject() retrieves instances from Koin and remembers them across recompositions:

@Composable
fun MyScreen() {
// Resolved once, remembered across recompositions
val repository = koinInject<UserRepository>()

// Safe - uses the same instance
val users by repository.users.collectAsState()
}

Injection Timing​

Inject dependencies at the Composable function level, not inside callbacks:

@Composable
fun MyScreen() {
// Correct - resolved at composition time
val repository = koinInject<UserRepository>()
val viewModel = koinViewModel<MyViewModel>()

Button(onClick = {
// Wrong - don't inject in callbacks
val service = koinInject<Service>() // Avoid!

// Correct - use already-injected instance
repository.save()
}) {
Text("Save")
}
}

Performance with Parameters​

When using parameters with koinInject, prefer the explicit parameter form:

@Composable
fun MyScreen(userId: String) {
// More efficient - parameters evaluated once
val presenter = koinInject<UserPresenter>(
parameters = parametersOf(userId)
)

// Less efficient - lambda re-evaluated on recomposition
val presenter = koinInject<UserPresenter> {
parametersOf(userId)
}
}

State Management with Koin​

StateFlow and collectAsState​

The standard pattern for reactive UI with Koin:

@KoinViewModel
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()

init {
loadUsers()
}

private fun loadUsers() {
viewModelScope.launch {
_state.value = UiState.Success(repository.getUsers())
}
}
}

@Composable
fun UserScreen(
viewModel: UserViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()

when (val s = state) {
is UiState.Loading -> LoadingIndicator()
is UiState.Success -> UserList(s.users)
is UiState.Error -> ErrorMessage(s.message)
}
}

Direct Repository Injection​

For simpler cases, inject repositories directly:

@Singleton
class UserRepository {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
}

@Composable
fun UserListScreen() {
val repository = koinInject<UserRepository>()
val users by repository.users.collectAsState()

LazyColumn {
items(users) { user ->
UserCard(user)
}
}
}

remember() vs koinInject()​

Use the right tool for each job:

@Composable
fun MyScreen() {
// Koin-managed dependencies
val viewModel = koinViewModel<MyViewModel>()
val repository = koinInject<Repository>()

// Compose-managed state
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
var text by remember { mutableStateOf("") }

// Don't wrap koinInject in remember (unnecessary)
val service = remember { koinInject<Service>() } // Redundant!
}

Side Effects with Koin​

LaunchedEffect​

Execute suspending code when composition enters or keys change:

@Composable
fun UserDetailScreen(userId: String) {
val repository = koinInject<UserRepository>()
var user by remember { mutableStateOf<User?>(null) }

// Runs when userId changes
LaunchedEffect(userId) {
user = repository.getUser(userId)
}

user?.let { UserContent(it) }
}

DisposableEffect​

Clean up resources when leaving composition:

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

DisposableEffect(Unit) {
val listener = eventBus.subscribe { event ->
// Handle event
}

onDispose {
eventBus.unsubscribe(listener)
}
}
}

SideEffect​

Execute non-suspending side effects after every successful recomposition:

@Composable
fun AnalyticsScreen(screenName: String) {
val analytics = koinInject<Analytics>()

SideEffect {
analytics.logScreenView(screenName)
}
}

Stability and Skipping​

Understanding Stable Types​

Compose can skip recomposition when inputs haven't changed. For this to work, parameter types must be stable:

// Stable - Compose can skip
@Composable
fun UserCard(
name: String, // Primitive - stable
onClick: () -> Unit, // Lambda - stable
viewModel: UserViewModel = koinViewModel() // Treated as stable
)

// Potentially unstable - may not skip
@Composable
fun UserCard(
user: User // Data class - stable if all properties stable
)

Koin Injections and Stability​

Koin injections are treated as stable because they return the same instance (for singletons) or are remembered:

@Composable
fun MyScreen() {
// Stable - singleton returns same instance
val repository = koinInject<UserRepository>()

// Stable - ViewModel is remembered
val viewModel = koinViewModel<MyViewModel>()
}

Passing Parameters vs Injection​

Decision Guide​

Pass as ParameterInject with Koin
Changes frequently (userId, query)Stable dependencies (repositories, services)
UI state (selected item)Infrastructure (database, network)
Navigation argumentsBusiness logic (use cases)
Parent-provided dataViewModels

Example Pattern​

// userId changes - pass as parameter
// repository is stable - inject
@Composable
fun UserProfile(
userId: String,
repository: UserRepository = koinInject()
) {
var user by remember { mutableStateOf<User?>(null) }

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

user?.let { ProfileContent(it) }
}

// Pure composable - no injection needed
@Composable
fun ProfileContent(user: User) {
Column {
Text(user.name)
Text(user.email)
}
}

Best Practices​

1. Inject at the Top Level​

@Composable
fun FeatureScreen() {
// Inject here
val viewModel = koinViewModel<FeatureViewModel>()
val repository = koinInject<FeatureRepository>()

// Pass down to children
FeatureContent(
state = viewModel.state,
onAction = viewModel::handleAction
)
}

2. Keep Child Composables Pure​

// Pure - receives all data as parameters
@Composable
fun UserCard(
user: User,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
// No injection here
}

3. Use ViewModel for Complex State​

// Complex state management in ViewModel
@KoinViewModel
class SearchViewModel(
private val searchRepository: SearchRepository
) : ViewModel() {
var query by mutableStateOf("")
private set

private val _results = MutableStateFlow<List<Result>>(emptyList())
val results = _results.asStateFlow()

fun updateQuery(newQuery: String) {
query = newQuery
viewModelScope.launch {
_results.value = searchRepository.search(newQuery)
}
}
}

4. Avoid Injection in Loops​

@Composable
fun UserList(userIds: List<String>) {
// Inject once outside the loop
val repository = koinInject<UserRepository>()

LazyColumn {
items(userIds) { userId ->
// Don't inject inside items!
UserCard(userId, repository)
}
}
}

Next Steps​