Side Effects & Lifecycle

Jetpack Compose side effects — LaunchedEffect, DisposableEffect, rememberCoroutineScope, produceState, and lifecycle awareness

Side effects in Compose let you interact with the outside world — network calls, databases, subscriptions — in a safe, predictable way.

LaunchedEffect

Runs a suspend function when the key changes. Cancels and restarts if the key changes. Cancels when leaving composition.

@Composable
fun UserProfile(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }

    // Runs when userId changes; cancels previous
    LaunchedEffect(userId) {
        user = userRepository.getUser(userId)
    }

    if (user != null) {
        Text(user!!.name)
    } else {
        CircularProgressIndicator()
    }
}

// Keys — restart when any key changes
LaunchedEffect(userId, refreshTrigger) {
    data = repository.loadData(userId)
}

rememberCoroutineScope

Returns a CoroutineScope that’s cancelled when leaving composition. Use for launching coroutines from event handlers (not during composition).

@Composable
fun SaveButton(viewModel: SaveViewModel) {
    val scope = rememberCoroutineScope()
    val snackbarHostState = remember { SnackbarHostState() }

    Button(onClick = {
        scope.launch {
            viewModel.save()
            snackbarHostState.showSnackbar("Saved!")
        }
    }) {
        Text("Save")
    }
}

DisposableEffect

For side effects that need cleanup. Runs when keys change and on dispose.

@Composable
fun NetworkObserver(networkCallback: NetworkCallback) {
    val context = LocalContext.current
    val connectivityManager = remember {
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    }

    DisposableEffect(Unit) {
        connectivityManager.registerNetworkCallback(
            NetworkRequest.Builder().build(),
            networkCallback
        )
        onDispose {
            connectivityManager.unregisterNetworkCallback(networkCallback)
        }
    }
}

// With keys — re-register when key changes
DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event -> /* handle */ }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose {
        lifecycleOwner.lifecycle.removeObserver(observer)
    }
}

produceState

Converts a non-Compose state source into Compose state. Useful for converting Flow, LiveData, or streams.

@Composable
fun loadUser(userId: String): State<Result<User>> {
    return produceState(initialValue = Result.Loading, key1 = userId) {
        value = userRepository.getUser(userId)
    }
}

// Usage
val userResult by loadUser(userId = "123")
when (userResult) {
    is Result.Loading -> CircularProgressIndicator()
    is Result.Success -> UserView(userResult.data)
    is Result.Error -> ErrorMessage(userResult.message)
}

derivedStateOf

Creates a state that’s derived from other states. Only triggers recomposition when the derived value changes.

@Composable
fun TodoList(todos: List<Todo>) {
    val showClearButton by remember {
        derivedStateOf {
            todos.any { it.completed }
        }
    }

    // Only recomposes when showClearButton changes, not on every todo edit
    if (showClearButton) {
        IconButton(onClick = { /* clear completed */ }) {
            Icon(Icons.Default.Delete, "Clear completed")
        }
    }
}

// Scroll-based visibility
@Composable
fun CollapsibleHeader(scrollState: ScrollState) {
    val isCollapsed by remember {
        derivedStateOf { scrollState.value > 100 }
    }

    AnimatedVisibility(visible = !isCollapsed) {
        Text("Expanded Header")
    }
}

snapshotFlow

Converts Compose state changes into a Kotlin Flow. Useful for analytics, debounced saves, etc.

@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    var query by remember { mutableStateOf("") }

    // Debounced search
    LaunchedEffect(Unit) {
        snapshotFlow { query }
            .debounce(300)
            .distinctUntilChanged()
            .collect { q ->
                viewModel.search(q)
            }
    }

    TextField(
        value = query,
        onValueChange = { query = it },
        label = { Text("Search") }
    )
}

SideEffect

Runs after every successful recomposition. Use to notify non-Compose code of Compose state.

@Composable
fun AnalyticsScreen(screenName: String) {
    // Publish to analytics after every recomposition
    SideEffect {
        analytics.trackScreenView(screenName)
    }

    Text(screenName)
}

Lifecycle awareness

// Observe lifecycle events
@Composable
fun LifecycleObserver() {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_RESUME -> { /* resume logic */ }
                Lifecycle.Event.ON_PAUSE -> { /* pause logic */ }
                else -> {}
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
    }
}

// Collect from ViewModel with lifecycle awareness
@Composable
fun MyScreen(viewModel: MyViewModel) {
    // Stops collecting when app is backgrounded
    val state by viewModel.state.collectAsStateWithLifecycle()

    // vs. collectAsState (keeps collecting in background)
    // val state by viewModel.state.collectAsState()
}

Which side effect to use?

Effect Use when Cleanup Keyed
LaunchedEffect Run suspend function on key change Auto-cancel Yes
rememberCoroutineScope Launch from event handlers Auto-cancel on dispose No
DisposableEffect Register/unregister callbacks onDispose block Yes
produceState Convert external data to state Auto-cancel Yes
derivedStateOf Compute derived state efficiently N/A N/A
snapshotFlow Convert state changes to Flow Auto-cancel N/A
SideEffect Notify non-Compose after recomposition N/A No