Previewing retain{} API: A New Way to Persist State in Jetpack Compose

Jaewoong Eum on 2025-08-01

Unsplash (glenncarstenspeters)

Jetpack Compose has reshaped how we think about UI in Android. With its declarative nature, reactive state model, and composable functions, it has drastically reduced boilerplate and improved clarity in UI logic. But even with all its strengths, Compose has long lacked one mechanism: a native way to persist state across the destruction and recreation of the composition hierarchy.

We use remember to restore values through recompositions. This works well in most cases, until it doesn’t. Once the entire composition is removed—say, due to a configuration change, collapsing content, or a navigation pop—those remembered values are gone. You’re left reloading data or recreating objects you just initialized a moment ago. That’s not only inefficient, but also potentially frustrating for users.

It looks like development is still ongoing in AOSP, and they’ve recently worked on a new retain API in Jetpack Compose Runtime. This addition fills a long-standing gap in Compose by allowing values to outlive recompositions and survive transient removals from the UI hierarchy. With retain, Compose can now manage state in a way that closely resembles the behavior of ViewModels, yet in a purely composable, lifecycle-independent fashion.

For the following articles, you can check out: - Part 2: Previewing RetainedEffect: A New Side Effect to Bridge Between Composition and Retention Lifecycles - Part 3: Understanding retain{} internals: A Scope-based State Preservation in Jetpack Compose

Understanding What Makes retain Different

If you’ve used remember, you already understand part of what retain does. But the key difference lies in how long values are kept. While remember only survives recompositions, retain allows values to persist even when the associated composable leaves the composition entirely.

For instance, if a screen is navigated away from but remains in the back stack, or if an Activity is recreated during a rotation, any value remembered with remember will be discarded. In contrast, a value created with retain can remain in memory and be brought back when the composable returns to the composition.

Let’s take a closer look at the API surface of retain.

retain.kt

 /**
 * ┌──────────────────────┐
 * │                      │
 * │ retain(keys) { ... } │
 * │        ┌────────────┐│
 * └────────┤  value: T  ├┘
 *          └──┬─────────┘
 *             │   ▲
 *         Exit│   │Enter
 *  composition│   │composition
 *    or change│   │
 *         keys│   │                         ┌──────────────────────────┐
 *             │   ├───No retained value─────┤   calculation: () -> T   │
 *             │   │   or different keys     └──────────────────────────┘
 *             │   │                         ┌──────────────────────────┐
 *             │   └───Re-enter composition──┤    Local RetainScope     │
 *             │       with the same keys    └─────────────────┬────────┘
 *             │                                           ▲   │
 *             │                      ┌─Yes────────────────┘   │ value not
 *             │                      │                        │ restored and
 *             │   .──────────────────┴──────────────────.     │ scope stops
 *             └─▶(   RetainScope.isKeepingExitedValues   )    │ keeping exited
 *                 `──────────────────┬──────────────────'     │ values
 *                                    │                        ▼
 *                                    │      ┌──────────────────────────┐
 *                                    └─No──▶│     value is retired     │
 *                                           └──────────────────────────┘
 * ```
 *
 * @param calculation A computation to invoke to create a new value, which will be used when a
 *   previous one is not available to return because it was neither remembered nor retained.
 * @return The result of [calculation]
 * @throws IllegalArgumentException if the return result of [calculation] both implements
 *   [RememberObserver] and does not also implement [RetainObserver]
 * @see remember
 */
@Composable
public inline fun <reified T> retain(noinline calculation: @DisallowComposableCalls () -> T): T {
    return retain(
        positionalKey = currentCompositeKeyHashCode,
        typeHash = classHash<T>(),
        calculation = calculation,
    )
}

This makes retain particularly useful in common real-world scenarios: holding onto user input while navigating, maintaining the UI state of an off-screen tab, or avoiding network re-fetching during a rotation.

RetainScope internals

retain lies in how it's scoped, not to an Android lifecycle, but to a RetainScope. This is a composition-local boundary that manages how and when values are stored, kept, or discarded.

The default ForgetfulRetainScope behaves just like remember, but new implementations will integrate with lifecycle-aware scopes like activities and navigation graphs.

A RetainScope serves as the memory space where values remembered by retain are stored. More importantly, it defines the rules for when those values should be kept after leaving the composition, and when they should be discarded.

The typical retention flow looks like this:

1. Retention begins: The RetainScope is notified that a piece of content is about to be temporarily removed, such as during a configuration change or screen transition. At this point, the owner of the scope should call requestKeepExitedValues() to signal that values should be preserved.

2. Content exits composition: As the composable is removed, its remember-ed values are forgotten. But values created with retain are handed off to the scope through saveExitingValue(), so they can potentially be restored later.

3. Content re-enters: Some time later, if the composable is recomposed at the same position and with matching keys, retain calls getExitedValueOrDefault(). If a retained value exists, it’s returned and reattached to the composition.

4. Retention ends: Once the UI is fully restored, the owner should call unRequestKeepExitedValues(). Any retained values that weren’t reused are immediately discarded.

So a RetainScope is a composition-scoped object that tracks values remembered with retain. It determines whether a retained value should be held in memory after leaving the composition, and when it should be restored or discarded.

Under the hood, the Compose runtime uses the LocalRetainScope composition local to provide access to the current RetainScope. By default, this is set to a ForgetfulRetainScope, which means that retain behaves identically to remember. However, in Android, lifecycle-aware scopes (such as those tied to an Activity or navigation graph) can replace this default to provide persistence across more complex transitions.

forgetful_retain_scope.kt

/**
 * The ForgetfulRetainScope is an implementation of [RetainScope] that is incapable of keeping any
 * exited values. When installed as the [LocalRetainScope], all invocations of [retain] will behave
 * like a standard [remember]. [RetainObserver] callbacks are still dispatched instead of
 * [RememberObserver] callbacks, meaning that this class will always immediately retire a value as
 * soon as it exits composition.
 */
public object ForgetfulRetainScope : RetainScope() {
    override fun onStartKeepingExitedValues() {
        throw UnsupportedOperationException("ForgetfulRetainScope can never keep exited values.")
    }

    override fun onStopKeepingExitedValues() {
        // Do nothing. This implementation never keeps exited values.
    }

    override fun getExitedValueOrDefault(key: Any, defaultIfAbsent: Any?): Any? {
        return defaultIfAbsent
    }

    override fun saveExitingValue(key: Any, value: Any?) {
        throw UnsupportedOperationException("ForgetfulRetainScope can never keep exited values.")
    }
}

Internally, any value remembered with retain is registered with the active RetainScope. As content leaves and re-enters the composition, the scope controls which values survive and which get discarded.

A Look Inside: How retain Actually Works

On the surface, using retain feels almost identical to remember. You write code like this:

val user = retain { someData() }

But internally, Compose handles it differently. Each retain call is uniquely keyed using both positional information (via currentCompositeKeyHashCode) and optional user-provided keys. These are wrapped in a RetainKeys object and stored in a RetainedValueHolder, which tracks the current value and its ownership by a RetainScope.

When the composable leaves the composition, the RetainScope determines whether to keep or discard the value. If retention is active—say, because the screen is in the navigation back stack or an Activity is undergoing recreation—the value is kept in memory and will be re-injected when the composable returns.

This retention behavior is also observable. If your retained object implements RetainObserver, it will receive lifecycle-style callbacks: onRetained(), onEnteredComposition(), onExitedComposition(), and finally, onRetired(). These callbacks allow you to manage resources appropriately and avoid memory leaks or unexpected behavior.

Another rule is that any value implementing RememberObserver must also implement RetainObserver when used with retain. Otherwise, an exception will be thrown. This ensures that Compose always knows how to manage the lifecycle of retained objects.

Compose-Native Persistence Without ViewModel

One of the most compelling aspects of retain is how it redefines state persistence without relying on the traditional Android lifecycle. With retain, there’s no need to hoist logic into a ViewModel, no need for SavedStateHandle, and no coupling to Activity or Fragment lifecycles. Everything happens inside the composition tree, where it belongs.

That doesn’t mean ViewModels are obsolete, far from it. They’re still essential for shared state across screens or when integrating with Android APIs. But retain offers a more precise, lightweight solution when the need is localized. It’s especially useful in dynamic, nested UI structures where reaching for a ViewModel might feel like overkill.

Consider a detailed screen in a navigation flow. With retain, you can fetch and cache data like this:

retain_article.kt

@Composable
fun ArticleScreen(articleId: String) {
    val article = retain(articleId) { loadArticle(articleId) }
    ArticleContent(article)
}

Now, even if the user leaves the screen and returns shortly after, the article remains cached in memory. There’s no redundant re-fetching, and no unexpected UI resets. You get fluid transitions and consistent state, all driven by the Compose runtime.

The Lifecycle of a Retained Value

Every retained value follows a well-defined lifecycle. Initially, the value is created using the lambda you provide. When the composable exits, Compose consults the RetainScope to decide whether to keep it alive. If so, the value is held in memory but not active in the composition.

Later, if the composable re-enters the hierarchy with matching keys, Compose reuses the existing value rather than invoking the lambda again. Otherwise, the lambda runs anew, just as it would with remember.

Once the retention duration ends, when the scope is no longer keeping exited values, all retained objects that didn’t re-enter the composition are disposed. If your retained object implements RetainObserver, this is where onRetired() is called, giving you a clear opportunity to release resources or perform cleanup.

Conclusion

The introduction of retain in Compose Runtime is a great step forward in making Compose a truly self-sufficient UI system. It bridges the gap between short-lived state and long-lived memory, letting you persist values through transitions without ever leaving the composable world.

Whether you’re building complex navigation flows, handling configuration changes, or managing ephemeral UI state, retain offers a promising new mechanism for restoring state across composition boundaries in the Compose Runtime.

If you’re looking to stay sharp with the latest skills, news, technical articles, interview questions, and practical code tips, check out Dove Letter. And for a deeper dive into interview prep, don’t miss the ultimate Android interview guide: Manifest Android Interview.

In the next article, “Previewing RetainedEffect: A New Side Effect to Bridge Between Composition and Retention Lifecycles”, we will examine RetainedEffect’s internal workflow to better understand how Compose manages these long-lived operations and what differentiates this new API from its predecessors.