Call side-effecting code straight from a composable's body and it runs on every recomposition, with no cleanup. Compose gives you three purpose-built escape hatches instead. Pick the wrong one and you either leak resources or run the work at the wrong frequency.
#The old way
@Composable
fun LocationScreen(locationClient: LocationClient) {
locationClient.startUpdates()
Text("Tracking location")
}
startUpdates() runs again on every recomposition, and it never tears itself down. Duplicate subscriptions. Leaked listeners the moment this leaves composition.
#The new way
@Composable
fun LocationScreen(
userId: String,
locationClient: LocationClient,
analytics: Analytics
) {
var location by remember { mutableStateOf<Location?>(null) }
LaunchedEffect(userId) {
location = locationClient.fetchInitialLocation(userId)
}
DisposableEffect(locationClient) {
val listener = LocationListener { location = it }
locationClient.addListener(listener)
onDispose { locationClient.removeListener(listener) }
}
SideEffect {
analytics.setUserProperty("last_screen", "location")
}
Text(text = location?.toString() ?: "Locating…")
}
#Why it matters
LaunchedEffectruns a coroutine scoped to composition. Change its keys and that coroutine is cancelled and restarted.DisposableEffectpairs setup withonDispose, so subscriptions to non-Compose callback APIs —addListener/removeListenerpairs like the one above — always get torn down.SideEffectruns after every successful recomposition. Reach for it when you need to publish Compose state to something Compose doesn't own: an analytics SDK, a legacy View.
#Gotcha
LaunchedEffect(Unit) never restarts on recomposition. Not when userId changes, not when anything else does. If the effect body reads a value that should trigger a restart, that value belongs in the key list — being in scope is not enough, and its changes will be silently ignored.