DEV Community

Cover image for Keep the Expensive Stuff Alive: A Field Guide to retain in Jetpack Compose
Android Expert
Android Expert

Posted on Originally published at Medium

Keep the Expensive Stuff Alive: A Field Guide to retain in Jetpack Compose

Every Compose codebase eventually grows the same awkward corner: a composable that needs one heavyweight object, a search session, a player wrapper, a debounced event pipeline, and that object has to survive a rotation. The usual answers are all slightly wrong for the job.

  • remember forgets everything on a configuration change.
  • rememberSaveable demands something a Bundle can carry.
  • A ViewModel works, but for a single screen-scoped helper it is a lot of ceremony.

retain fills that gap. It shipped stable in Compose 1.10 (BOM 2025.12.00), and this post covers what it does, where it fits, and the failure modes you will hit in production. (APIs this young move, so check signatures against the current release notes before copying code verbatim.)

The mental model

remember stores a value in the composition. When the Activity is recreated, the composition is thrown away and the value goes with it.

rememberSaveable stores a serialized copy in saved instance state. The value is rebuilt from that copy, so it must be Bundle-friendly and small, since the Bundle is shared with a size-limited cross-process transaction.

retain does neither. It keeps the actual instance alive in memory while the Activity is torn down and rebuilt, then hands the same instance back to the new composition. Nothing is serialized, so there is no type restriction and no Bundle budget.

retain-comparison-card

Two API facts to know up front. retain lives in the androidx.compose.runtime.retain package of Compose Runtime 1.10+. And it retains only as far as the surrounding RetainedValuesStore allows: the default store is forgetful, which makes retain behave exactly like remember. Compose UI's Android host supplies a store that survives Activity recreation, but if you build custom hosts or tests, check which store is in play. Also, retaining an object that implements RememberObserver without also implementing RetainObserver throws, and you can mark a class with @DoNotRetain to forbid retaining it.

The trade-off is in the last column of that card: retained values live on the process heap. They have no answer for process death.

retain-lifecycle-matrix

A first example

Here is a search screen that owns a small pipeline: a query flow, a debounce, and a result stream. Rebuilding it on rotation would cancel in-flight requests and flash the UI.

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.retain.RetainObserver
import androidx.compose.runtime.retain.retain
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class CatalogSearch(private val api: CatalogApi) : RetainObserver {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private val queries = MutableStateFlow("")

    val results: StateFlow<List<Item>> = queries
        .debounce(250)
        .distinctUntilChanged()
        .mapLatest { q -> if (q.isBlank()) emptyList() else api.search(q) }
        .stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun onQueryChanged(q: String) { queries.value = q }

    override fun onRetained() = Unit
    override fun onEnteredComposition() = Unit
    override fun onExitedComposition() = Unit
    override fun onUnused() = scope.cancel()   // created but never applied
    override fun onRetired() = scope.cancel()  // screen is gone for good
}

@Composable
fun CatalogScreen(api: CatalogApi) {
    val search = retain(api) { CatalogSearch(api) }
    val results by search.results.collectAsStateWithLifecycle()

    SearchField(onChange = search::onQueryChanged)
    ResultList(results)
}
Enter fullscreen mode Exit fullscreen mode

Three details matter here. The api key rebuilds the object if the dependency changes. Collection uses collectAsStateWithLifecycle() (from lifecycle-runtime-compose) so it pauses in the background. And the class implements RetainObserver, so its scope is cancelled when the value is retired rather than leaking until process death.

Rotate the device mid-typing: the same CatalogSearch instance, same scope, same debounce window. No Parcelable, no ViewModelProvider.

Retained state that drives recomposition

retain stores whatever you hand it. If you want a value that is both retained and observable, retain a MutableState:

@Composable
fun TapCounter() {
    val count = retain { mutableIntStateOf(0) }

    Button(onClick = { count.intValue++ }) {
        Text("Taps: ${count.intValue}")
    }
}
Enter fullscreen mode Exit fullscreen mode

mutableIntStateOf avoids boxing for a primitive counter, and the retained state holder is the same instance after rotation. Writes to it still trigger recomposition exactly as they would with remember.

Choosing between the options

retain-decision-flow

A rule of thumb that has held up well: if losing the value on a rotation would hurt, but losing it after process death is acceptable, retain is a candidate. If the value must also survive process death, you need rememberSaveable or a ViewModel backed by SavedStateHandle.

retain is also screen-level by design. Per-item state inside LazyColumn items is a poor fit, because items enter and leave composition constantly while scrolling. Hoist that state into a ViewModel, or use rememberSaveable keyed by a stable item ID.

Teardown: the part people forget

A retained object outlives the composition that created it. That is the point, and also the hazard. Something has to release it when the screen is actually done, not merely rotated.

Compose provides RetainObserver for this. An object is retained when retain first returns it, may enter and exit composition any number of times (rotation is exactly that), and is retired once it is removed from composition and no store is keeping it. The interface has five callbacks: onRetained, onEnteredComposition, onExitedComposition, onRetired, and onUnused, which fires when the value was created but the composition was abandoned before applying it. The opening example already shows the pattern; cancel in both onRetired and onUnused.

For one-off side effects that don’t belong inside a class, RetainedEffect gives you a DisposableEffect-style block whose cleanup runs on retirement rather than on every rotation.

Tie cancellation to retirement, never to onDispose of a DisposableEffect. onDispose fires on every rotation, which would cancel the very scope you just worked to preserve.

Four failure modes from production

retain-failure-modes

1. The leaked Activity

The classic. A retained object captures a Context obtained from LocalContext.current, which is the Activity. After rotation the old Activity cannot be collected, because your retained object still points at it.

// Leaks the Activity across every rotation
val player = retain { VideoSession(LocalContext.current) }

// Safer: pass only what the object genuinely needs
val appContext = LocalContext.current.applicationContext
val player = retain { VideoSession(appContext) }
Enter fullscreen mode Exit fullscreen mode

Better still, inject the collaborators an object needs (an ExoPlayerFactory, a repository) instead of any Context. Audit every constructor argument of a retained object for UI references: views, Activities, Resources tied to a themed context, listeners that close over composables.

2. Nothing ever shuts it down

Retained coroutine scopes keep collecting. Retained players keep their codecs. Retained socket wrappers keep sockets. If the screen is dismissed and nothing cancels the work, it runs until the process dies. Use onRetired/onUnused as shown above, and verify it with a leak-canary style test that pops the screen and asserts the scope is cancelled.

3. Assuming it survives process death

Retained values are plain heap objects. When the system reclaims your process in the background, they are gone, and the restored screen starts from scratch. Anything the user would be upset to lose, a draft, a selected ID, a scroll anchor, must also go through rememberSaveable or SavedStateHandle.

A pattern that works well is to save the identity and retain the machinery:

class ArticleLoader(private val repo: ArticleRepository) : RetainObserver {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    private var loadedId: String? = null
    val state = MutableStateFlow<ArticleUiState>(ArticleUiState.Idle)

    fun load(id: String) {
        if (id == loadedId) return        // idempotent: a rotation must not reload
        loadedId = id
        scope.launch { state.value = ArticleUiState.Ready(repo.fetch(id)) }
    }

    override fun onRetained() = Unit
    override fun onEnteredComposition() = Unit
    override fun onExitedComposition() = Unit
    override fun onUnused() = scope.cancel()
    override fun onRetired() = scope.cancel()
}

@Composable
fun ArticleScreen(repo: ArticleRepository) {
    var articleId by rememberSaveable { mutableStateOf<String?>(null) }
    val loader = retain(repo) { ArticleLoader(repo) }
    val ui by loader.state.collectAsStateWithLifecycle()

    LaunchedEffect(articleId) { articleId?.let(loader::load) }
    // render ui ...
}
Enter fullscreen mode Exit fullscreen mode

LaunchedEffect re-runs after every recreation, so the retained loader makes load idempotent. After a rotation it is a no-op; after process death the loader is new, loadedId is null, and the saved ID triggers a fresh load.

4. Stale inputs

A retained lambda runs once. If the object was built from a parameter that later changes, it keeps using the old one. Pass the changing inputs as keys so the value is rebuilt when they change:

val search = retain(api, userId) { CatalogSearch(api, userId) }
Enter fullscreen mode Exit fullscreen mode

When the keys change, the previous instance is retired (and your teardown runs) before the new one takes over. That is exactly why teardown belongs in the retirement callback.

Testing notes

Retained values tie directly to Activity recreation, so unit tests on composables will not catch most of these bugs. Use an instrumented test that calls ActivityScenario.recreate() and asserts two things: the object identity is unchanged after recreation, and the teardown fires only after the screen is removed. Also run your leak detector across a rotation; a leaked Activity from failure mode one shows up immediately.

When I would still reach for a ViewModel

  • The state must survive process death through SavedStateHandle.
  • Several screens or destinations share the same object.
  • Your team already has conventions, tooling, and test fixtures built around ViewModel.
  • The object participates in per-item logic inside lazy layouts.

retain is not a ViewModel replacement. It is a smaller tool for a narrower, very common job: one screen, one heavy helper, rotation-safe, no ceremony. Compose-first architectures such as Circuit, where presenters are themselves composable, are a natural home for it.

🔚 Takeaways

  1. retain keeps the real instance alive across configuration changes, with no serialization and no Bundle limit.
  2. It retains only as far as the host’s RetainedValuesStore allows, and it does not survive process death. Save identity with rememberSaveable, retain machinery.
  3. Wrap mutableStateOf (or its primitive variants) inside retain when you need retained state that drives recomposition.
  4. Never capture an Activity-scoped Context in a retained object.
  5. Release resources on retirement, not on onDispose.
  6. Keep it screen-level; hoist per-item state out of lazy layouts.

Are you already using retain in a real project? I would like to hear which failure mode bit you first.

📱 New to Jetpack Compose? Start Here

If you’re coming from the classic Android View system — XML layouts, findViewById, RecyclerView adapters — and want a clear, no-jargon path into Jetpack Compose, I recommend "Learning Jetpack Compose: Your Practical Guide from the Android View System to Modern Declarative UI." Every chapter starts from something you already know and shows its Compose equivalent side by side, building up to a full capstone: a real Notes app. Once you're comfortable and want to go deeper — the Composition tree, recomposition internals, the runtime itself — "Mastering Jetpack Compose Internals" is the natural next step.


Originally published on Medium.

Top comments (0)