Lesson 4

Retention has its own store and observer lifecycle

Follow RetainedValuesStore ownership, RetainObserver callbacks, RetainedEffect retirement, and DoNotRetain leak protection.

4 min readUpdated Jul 12, 2026

Status: Retention needs an owner beyond the slot table and therefore has a distinct callback vocabulary: Durable . Exact store handoffs, callback order, and lint enforcement are Version-specific .

Outcome

Trace a retained object through successful retention, composition exit, restoration or retirement, and cleanup. Explain why RetainedEffect is not just DisposableEffect with a longer delay.

The store owns the gap after forgetting

RetainedValuesStore defines the handoff around transient removal. Before owned content exits, onContentExitComposition tells the store to prepare. As retained holders are forgotten, saveExitingValue receives their keys and values. On re-entry, retain asks consumeExitedValueOrDefault; after the restored content finishes entering, onContentEnteredComposition lets the store discard unconsumed values (RetainedValuesStore.kt).

LocalRetainedValuesStoreProvider installs this boundary around content. It uses a remembered presence indicator and a frame-end callback so the store is told that content entered only after the content’s successful frame; forgetting the indicator starts exit (LocalRetainedValuesStore.kt). A store belongs to one recomposer and must be disposed when orphaned.

ManagedRetainedValuesStore is the configurable policy: disableRetainingExitedValues() purges held values, while dispose() permanently prevents future retention (ManagedRetainedValuesStore.kt). Retained does not mean forever; it means this owner may keep the object between composition memberships.

RetainObserver names the extra states

A retained object does not receive ordinary RememberObserver callbacks for this lifecycle. RetainObserver has five events (RetainObserver.kt):

EventMeaning
onRetainedA newly created value was successfully installed in the retention system
onEnteredCompositionCurrent content now references the value
onExitedCompositionContent stopped referencing it, but the store may keep it
onRetiredNo future restoration will use it; release resources
onUnusedA created value was abandoned before successful retention

Initial success calls onRetained then onEnteredComposition. A transient exit calls onExitedComposition; matching restoration enters again. Without a retaining store, exit retires immediately. onRetired has no universal order and may run on a different thread when an orphaned store is disposed.

Diagram
flowchart TD
    Create[Create retained value] --> Retained[onRetained]
    Retained --> Enter[onEnteredComposition]
    Enter --> Exit[onExitedComposition]
    Exit --> Restore{Store restores?}
    Restore -->|Yes| Enter
    Restore -->|No| Retire[onRetired]
    Create -->|Abandoned| Unused[onUnused]

RetainedEffect follows retirement, not forgetting

RetainedEffect(key...) wraps a RetainObserver. Its effect block runs when the retained observer is first retained, and onRetire { ... } runs when that observer is retired (RetainedEffect.kt). During a store-managed transient exit, the observer can remain alive without running its retirement callback; if the content returns with the same keys, it is restored. If the key changes, the old retained effect is eventually retired and a new one is retained. Without a retaining store, it behaves like an immediately retired disposable effect.

The no-key overload is a compile-time deprecation error; keyed declarations are @Composable @NonRestartableComposable with no experimental opt-in (RetainedEffect.kt). Release notes introduce it in 1.10.0-alpha04 as a retention-lifecycle effect (release notes).

Leak protection is part of the contract

The retain docs warn against Context, View, and indirect references. @DoNotRetain is a separately published binary class annotation; runtime-retain lint checks it on direct return types and keys (DoNotRetain.kt, runtime-annotation API). It is a guardrail, not proof of safety. Keys share the value’s retention duration.

Reproduce observer and retirement events

At the pinned checkout, run:

./gradlew :compose:runtime:runtime-retain:desktopTest \
  --tests 'androidx.compose.runtime.retain.RetainTests.retain_retaining_reconstruct' \
  --tests 'androidx.compose.runtime.retain.RetainTests.retain_callbackOrdering_relativeToRememberObserver' \
  --tests 'androidx.compose.runtime.retain.RetainedEffectTest.testRetainedEffect_keysChanged' \
  --tests 'androidx.compose.runtime.retain.RetainedEffectTest.testRetainedEffect_retireWhenRetainExitedValuesEnds'

Controls: remove and restore under an enabled store, compare callback logs, then change an effect key and disable the store. Expected: exit emits onExitedComposition, restoration enters without a new object, key change retires the old effect, and disabling the store retires held effects. Limits: desktop does not establish host callback threads or process death. This procedure was not executed for this lesson.

Misconceptions

  • “Retention is just a longer DisposableEffect.” The retained observer can cross a composition exit without retiring.
  • onExitedComposition means release now.” The store may restore the same object; release belongs to onRetired.
  • DoNotRetain makes retention safe.” It only flags selected classes and direct uses; indirect references and unannotated resources still require review.
  • “A retained key is ephemeral input.” The current implementation keeps keys while the retained value is kept.

Check yourself

A retained screen leaves composition, its store holds the object, and the screen returns with a different key. Which callback releases the old object, and why is onExitedComposition not sufficient?

Evidence ledger

ClaimDirect evidenceStatus
A store receives exit, save, consume, and enter handoffsRetainedValuesStore.kt and LocalRetainedValuesStore.ktVersion-specific
RetainObserver separates retained, entered, exited, retired, and unused statesRetainObserver.kt and RetainedValueHolder.ktDurable Version-specific
RetainedEffect retires on retention retirement and requires keysRetainedEffect.kt and RetainedEffectTest.ktVersion-specific
@DoNotRetain is a binary class annotation used by retention lintDoNotRetain.kt and 1.10.0-alpha02 release notesVersion-specific

Freshness

Refresh when store callbacks, RetainObserver thread/order guarantees, RetainedEffect retirement, or the retention lint detector changes. Treat onRetired as the release point, not a guessed frame boundary.

Finished this lesson?

Your progress stays only in this browser.