\ Jetpack Compose doesn’t “leak by default.” Most Compose leaks are plain old Kotlin reference leaks where something long-lived (a ViewModel, singleton, registry, static object, app scope coroutine) ends up holding a reference to something UI-scoped (an Activity Context, a composable lambda, a CoroutineScope, a remembered object).
If you internalize one idea, make it this:
ComposeView disposed → Compose runs disposals and cancels effect coroutines.LaunchedEffect loopThis cancels when the composable leaves composition.
@Composable fun PollWhileVisibleEffect() { LaunchedEffect(Unit) { while (true) { delay(1_000) // do polling work } } }
rememberCoroutineScope()The scope is cancelled when the composable leaves composition.
@Composable fun ShortLivedWorkButton() { val scope = rememberCoroutineScope() Button(onClick = { scope.launch { delay(300) // short-lived work } }) { Text("Run work") } }
GlobalScope / app-wide scope that outlives UIThis can keep references alive far past the screen’s lifecycle.
@Composable fun LeakyGlobalScopeExample() { val context = LocalContext.current Button(onClick = { // ❌ GlobalScope outlives the UI; captures 'context' (often Activity) GlobalScope.launch(Dispatchers.Main) { while (true) { delay(1_000) Toast.makeText(context, "Still running", Toast.LENGTH_SHORT).show() } } }) { Text("Start global job") } }
If the work is UI-only, keep it in UI (LaunchedEffect). If it’s app logic, run it in viewModelScope (and don’t capture UI stuff).
class PollingViewModel : ViewModel() { private var pollingJob: Job? = null fun startPolling() { if (pollingJob != null) return pollingJob = viewModelScope.launch { while (isActive) { delay(1_000) // business polling work (no Context!) } } } fun stopPolling() { pollingJob?.cancel() pollingJob = null } } @Composable fun ViewModelScopedPollingScreen(viewModel: PollingViewModel) { Column { Button(onClick = viewModel::startPolling) { Text("Start polling") } Button(onClick = viewModel::stopPolling) { Text("Stop polling") } } }
object LeakyAppSingleton { // ❌ Never store composable lambdas / UI callbacks globally var lastScreenContent: (@Composable () -> Unit)? = null } @Composable fun LeakySingletonProviderScreen() { val content: @Composable () -> Unit = { Text("This can capture composition state") } LeakyAppSingleton.lastScreenContent = content // ❌ content() }
If you need global coordination, use shared state (Flow) or interfaces with explicit unregister and no UI capture.
remember {} lambda captures + callback registered “forever”class MyViewModelWithCallbackRegistry : ViewModel() { private val callbacks = mutableSetOf<(String) -> Unit>() fun registerOnMessageCallback(callback: (String) -> Unit) { callbacks += callback } fun unregisterOnMessageCallback(callback: (String) -> Unit) { callbacks -= callback } fun emitMessage(message: String) { callbacks.forEach { it(message) } } } @Composable fun LeakyCallbackRegistrationScreen( viewModel: MyViewModelWithCallbackRegistry ) { val context = LocalContext.current // Leaks if this callback is stored in a longer-lived owner (ViewModel) and never unregistered. val onMessageCallback: (String) -> Unit = remember { { msg -> Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } } LaunchedEffect(Unit) { viewModel.registerOnMessageCallback(onMessageCallback) // ❌ no unregister } Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) { Text("Emit message") } }
ViewModel → callbacks set → lambda → captured context (Activity) → entire UI graph
@Composable fun FixedCallbackRegistrationScreen( viewModel: MyViewModelWithCallbackRegistry ) { val context = LocalContext.current // If the Activity changes (configuration change), keep using the latest context // without re-registering the callback unnecessarily. val latestContext = rememberUpdatedState(context) DisposableEffect(viewModel) { val onMessageCallback: (String) -> Unit = { msg -> Toast.makeText(latestContext.value, msg, Toast.LENGTH_SHORT).show() } viewModel.registerOnMessageCallback(onMessageCallback) onDispose { viewModel.unregisterOnMessageCallback(onMessageCallback) } } Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) { Text("Emit message") } }
class LeakyComposableStorageViewModel : ViewModel() { // ❌ Storing composable lambdas is a hard "don't" private var storedComposable: (@Composable () -> Unit)? = null fun storeComposable(content: @Composable () -> Unit) { storedComposable = content } fun renderStoredComposable() { // Imagine some trigger calls it later... // (Even having this reference is enough to retain composition state.) } } @Composable fun LeakyComposableStoredInViewModelScreen( viewModel: LeakyComposableStorageViewModel ) { viewModel.storeComposable { Text("This composable can capture composition state and context") } Text("Screen content") }
data class FixedScreenUiState( val title: String = "", val isLoading: Boolean = false ) sealed interface FixedScreenUiEvent { data class ShowToast(val message: String) : FixedScreenUiEvent data class Navigate(val route: String) : FixedScreenUiEvent } class FixedStateDrivenViewModel : ViewModel() { private val _uiState = MutableStateFlow(FixedScreenUiState()) val uiState: StateFlow<FixedScreenUiState> = _uiState.asStateFlow() private val _events = MutableSharedFlow<FixedScreenUiEvent>(extraBufferCapacity = 64) val events: SharedFlow<FixedScreenUiEvent> = _events.asSharedFlow() fun onTitleChanged(newTitle: String) { _uiState.value = _uiState.value.copy(title = newTitle) } fun onSaveClicked() { _events.tryEmit(FixedScreenUiEvent.ShowToast("Saved")) } } @Composable fun FixedStateDrivenScreen(viewModel: FixedStateDrivenViewModel) { val state by viewModel.uiState.collectAsState() // or collectAsStateWithLifecycle() // Handle one-off events in UI layer (no UI references stored in VM) LaunchedEffect(viewModel) { viewModel.events.collect { event -> when (event) { is FixedScreenUiEvent.ShowToast -> { // UI decides how to show it // (Use LocalContext here; do NOT pass context into ViewModel) } is FixedScreenUiEvent.Navigate -> { // navController.navigate(event.route) } } } } Column { Text("Title: ${state.title}") Button(onClick = viewModel::onSaveClicked) { Text("Save") } } }
remember without keys (stale resource retention)class ExpensiveResource(private val id: String) { fun cleanup() { /* release */ } } @Composable fun LeakyRememberKeyExample(itemId: String) { // ❌ If itemId changes, this still holds the first ExpensiveResource forever (for this composable instance) val resource = remember { ExpensiveResource(itemId) } Text("Using resource for $itemId -> $resource") }
remember + cleanup@Composable fun FixedRememberKeyExample(itemId: String) { val resource = remember(itemId) { ExpensiveResource(itemId) } DisposableEffect(itemId) { onDispose { resource.cleanup() } } Text("Using resource for $itemId -> $resource") }
ComposeView in Fragments without disposal strategyIf you’re hosting Compose inside a Fragment via ComposeView, you must ensure the composition is disposed with the Fragment’s view lifecycle, not the Fragment instance.
class MyComposeHostFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) setContent { Text("Compose hosted in Fragment") } } } }
LeakyScreenA.Activity nameComposeViewRecomposer / Composition / CompositionImplViewModel, singleton, callback registry, static field, global coroutine jobs.Activity or Fragment with a chain through a callback/lambda.ComposeView or composition classes held by a static field.DisposableEffect(owner).StateFlow) and events (SharedFlow) instead.GlobalScope and app-wide scopes for UI work \n Use LaunchedEffect or viewModelScope depending on ownership.remember \n If the object depends on X, use remember(X).Context \n Don’t capture an Activity context into long-lived callbacks. Use rememberUpdatedState or redesign so the UI handles UI.Compose is not the villain. Your leaks are almost always one of these:
\


