remember vs rememberSaveable: deep dive into state management and recomposition in Jetpack Compose

Understanding the differences between remember and rememberSaveable by exploring their internal mechanisms, and how they relate to state and recomposition.

Jaewoong Eum
Published

Summary

remember and rememberSaveable are key APIs for retaining state values across recompositions. In this article, you’ll learn how they differ by exploring their internal mechanisms and understanding their relationship to state and recomposition.

Jetpack Compose enables you to design and build UI components and screens in a declarative way, and this approach is powered by several key concepts. One of the most important is idempotence, the idea that a composable function will always produce the same UI output given the same input parameters. This ensures consistent rendering and helps Compose efficiently manage recompositions and UI updates.

In this article, you’ll explore the concept of recomposition in Jetpack Compose, dive into the internal workings of remember versus rememberSaveable, and learn how these APIs are used in real-world scenarios through examples from RevenueCat’s Android SDK.

Compose Phases and Recomposition

Before we dig into how remember and rememberSaveable work under the hood, let’s first understand why they’re needed in the first place, and what they actually do in Jetpack Compose.

The Jetpack Compose rendering pipeline is built around three core phases that define how UI is constructed and updated: Composition, Layout, and Drawing. These phases work together to form a unidirectional data flow, starting from the UI description and ending with pixels on the screen.

  • CompositionWhat to show: Composable functions are executed to generate a UI tree that describes the current screen state.
  • LayoutWhere to show it: Each element in the tree is measured and positioned within a 2D space, considering constraints and child elements.
  • DrawingHow to render it: The positioned elements are drawn onto a Canvas, typically mapped to the device screen.

Understanding this three-phase pipeline is key to writing a performant and maintainable UI in Compose, especially because these phases are tightly coupled with recomposition. As the name implies, recomposition restarts the pipeline from the composition phase, re-running your composable functions in response to state changes.

This means composable functions are re-invokable at any time, and you should write them assuming that they will be. However, without explicit state-handling mechanisms, every local variable inside a composable will be re-initialized on each recomposition, often leading to unexpected behavior.

That’s why the Compose Runtime introduces State to provide a way for values to survive recomposition and keep your UI in sync with the underlying data model.

Understanding State

Now, let’s take a moment to understand the nature of State in Jetpack Compose.

For example, consider the following simple function:

1@Composable
2fun Counter() {
3    var counter = 0
4
5    Button(
6        onClick = { counter++ }
7    ) {
8        Text(text = "Count: $counter")
9    }
10}

When you click the button, the counter variable should increase by 1, and the Counter composable function should re-render to reflect the updated state. However, it won’t behave as expected. That’s because Compose doesn’t automatically track regular variables, it relies on a special mechanism to detect changes and trigger recomposition.

State

To handle this properly, Compose provides a built-in mechanism called State. State allows the Compose runtime to observe value changes and automatically re-run the associated composable functions when needed. Once a state value changes, Compose will intelligently recompose only the affected parts of the UI.

Let’s update the function to use State below:

1@Composable
2fun Counter() {
3    var counter by mutableStateOf(0)
4
5    Button(
6        onClick = { counter++ }
7    ) {
8        Text(text = "Count: $counter")
9    }
10}

It looks like it should work, but you’ll notice a red underline under mutableStateOf, with an error saying: “Creating a state object during composition without using remember.”

So what’s the issue? Let’s break it down.

A function is an independently executable block of code, and in Jetpack Compose, a composable function can be re-executed at any time due to state changes (i.e. recomposition). That means the counter variable is re-initialized to 0 every time Counter() is recomposed.

To persist the value across recompositions, you need to tell Compose to retain it in memory, and that’s exactly what remember is for.

remember vs. rememberSaveable

To persist state across recompositions, Jetpack Compose provides two essential tools: remember and rememberSaveable. These APIs allow you to retain values even as your composable functions are re-executed, ensuring the UI remains consistent with user interactions.

Let’s revise the earlier example using remember to preserve the counter value:

1@Composable
2fun Counter() {
3    var counter by remember { mutableStateOf(0) }
4
5    Button(onClick = { counter++ }) {
6        Text(text = "Count: $counter")
7    }
8}

In this case, every time the Counter composable is recomposed, for example, after a state update, the current counter value is preserved in memory instead of being reinitialized.

Both remember and rememberSaveable serve a similar purpose: they cache values in the Compose runtime memory, giving the illusion of persistence within an otherwise stateless function. However, while they may appear similar on the surface, their behaviors diverge significantly behind the scenes.

In short, remember stores values in memory only as long as the process remains alive. If the app process is killed (e.g., backgrounded and reclaimed by the system), the state is lost. On the other hand, rememberSaveable serializes and stores the state in a Bundle, allowing it to survive configuration changes and process death, making it a better choice for preserving UI state across a wider range of lifecycle events.

We’ll explore those differences in more detail next.

remember

If you look into the internal implementation of remember, you’ll see it’s quite straightforward something like this:

1@Composable
2inline fun <T> remember(
3    key1: Any?,
4    crossinline calculation: @DisallowComposableCalls () -> T
5): T {
6    return currentComposer.cache(currentComposer.changed(key1), calculation)
7}

The remember function uses the Composer to cache a value in memory. This cached value is preserved across recompositions unless the provided key changes, which is how remember knows when to invalidate and recreate the value.

rememberSaveable

In Jetpack Compose, remember is an API for maintaining state across recompositions. However, its memory is ephemeral; it does not survive configuration changes or, more critically, system-initiated process death. This is where rememberSaveable steps in. It builds upon the foundation of remember but adds a crucial layer of persistence, ensuring that UI state can be saved to and restored from a Bundle, the standard Android mechanism for instance state.

A detailed analysis of its internal implementation reveals an internal mechanism that interacts with a SaveableStateRegistry, uses a Saver to convert objects into bundle-able types, and leverages the RememberObserver lifecycle to manage its registration. Let’s dissect the code to understand how it achieves this resilience.

1@Composable
2fun <T : Any> rememberSaveable(
3    vararg inputs: Any?,
4    saver: Saver<T, out Any> = autoSaver(),
5    key: String? = null,
6    init: () -> T
7): T {
8    // 1. Determine a Unique Key
9    val compositeKey = currentCompositeKeyHash
10    val finalKey = if (!key.isNullOrEmpty()) {
11        key
12    } else {
13        compositeKey.toString(MaxSupportedRadix)
14    }
15
16    // 2. Access the Registry
17    val registry = LocalSaveableStateRegistry.current
18
19    // 3. Remember the Holder Object
20    val holder = remember {
21        val restored = registry?.consumeRestored(finalKey)?.let { saver.restore(it) }
22        val finalValue = restored ?: init()
23        SaveableHolder(saver, registry, finalKey, finalValue, inputs)
24    }
25
26    // 4. Value and SideEffect for Updates
27    val value = holder.getValueIfInputsDidntChange(inputs) ?: init()
28    SideEffect { holder.update(saver, registry, finalKey, value, inputs) }
29
30    return value
31}

Let’s break down this orchestration step by step:

  1. Determining a Unique Key: The first and most critical step is establishing a unique key for the state being saved. This key is used to store and retrieve the value from the Bundle. The function prioritizes a developer-provided key. If none is given, it falls back to using the currentCompositeKeyHash, a value automatically generated by the Compose compiler based on the composable’s position in the UI tree. This ensures that the state is uniquely identified within its parent.
  2. Accessing the Registry: It then looks up the SaveableStateRegistry from a CompositionLocal. This registry is the central authority responsible for the entire save/restore process. It’s the component that actually interacts with the Android framework’s SavedStateHandle or Activity.onSaveInstanceState.
  3. Remembering the SaveableHolder: This is the core of the implementation. Instead of just remembering the value itself, it remembers an instance of a private helper class, SaveableHolder. This holder is where all the complex logic resides. During its initial creation inside remember, it attempts to restore a value. It asks the registry if it has a previously saved value for the finalKey (consumeRestored). If it does, it uses the provided saver object to convert the stored data back into its original type T (saver.restore(it)). If no restored value is found (e.g., on first launch), it uses the init lambda to create the initial value. This SaveableHolder is then stored in the composition’s memory.
  4. Handling Inputs and Updates: The vararg inputs parameter behaves similarly to the keys in LaunchedEffect. If the inputs change on a subsequent recomposition, it signals that the state should be reset. The line holder.getValueIfInputsDidntChange(inputs) ?: init() enforces this. The SideEffect ensures that any changes to the parameters (saver, registry, key, value, inputs) are communicated to the holder after every successful recomposition.

rememberSaveable is not a simple function but an intricate system designed to bridge the declarative, in-memory world of Compose with the persistent, lifecycle-aware world of the Android framework. It uses a unique key to identify state and a SaveableStateRegistry to manage the actual saving process.

The core logic is encapsulated within a SaveableHolder object, which is stored in the composition via remember. This holder acts as a RememberObserver, using onRemembered to register a value provider with the registry and onForgotten to unregister it, thus perfectly synchronizing the persistence lifecycle with the composition lifecycle. By also tracking inputs, it provides a powerful mechanism to not only survive process death but also to control when state should be reset based on external dependencies.

Now let’s explore SaveableHolder .

The SaveableHolder: The Internal Workhorse

The SaveableHolder class is a private implementation detail that encapsulates all the logic for interacting with the SaveableStateRegistry. It implements RememberObserver to hook into the composition lifecycle.

Let’s analyze its key responsibilities:

1private class SaveableHolder<T>(
2    // ... constructor properties ...
3) : SaverScope, RememberObserver {
4    private var entry: SaveableStateRegistry.Entry? = null
5    private val valueProvider = { /* saves the value */ }
6
7    // ... update function ...
8    fun update(
9        saver: Saver<T, Any>,
10        registry: SaveableStateRegistry?,
11        key: String,
12        value: T,
13        inputs: Array<out Any?>
14    ) {
15     ..
16    }
17
18    private fun register() {
19        // ... registers the valueProvider with the registry ...
20    }
21
22    override fun onRemembered() {
23        register()
24    }
25
26    override fun onForgotten() {
27        entry?.unregister()
28    }
29
30    // ... other functions ...
31}
  • Registration with the Registry (register and onRemembered): The SaveableHolder‘s most important job is to register itself with the SaveableStateRegistry. When the holder is first “remembered” by the composition, its onRemembered() lifecycle callback is invoked. This, in turn, calls the private register() function. Inside register(), it doesn’t pass its value directly to the registry. Instead, it provides a lambda, valueProvider. This provider, when called by the registry during the onSaveInstanceState event, will execute the saver logic to convert the current value into a Bundlecompatible format. The registry returns an Entry object, which is a handle that can be used later to unregister.
  • Unregistering (onForgotten): When the composable leaves the composition (or its rememberSaveable keys change), the onForgotten() callback is triggered. This is the cleanup phase. The holder calls entry?.unregister(), telling the SaveableStateRegistry that it no longer needs to save this piece of state. This prevents memory leaks and ensures that state from disposed UI is not saved unnecessarily.
  • Updating State (update): The update function is called within the SideEffect block. Its job is to keep the holder’s internal properties (like the current value, the saver, and the key) synchronized with the latest values from the composable function. If critical properties like the key or the registry itself change, it knows that its current registration is invalid, so it unregisters and re-registers itself to ensure correctness.
  • Handling Input Changes (getValueIfInputsDidntChange): This function is a simple but effective check. Before returning its stored value, it compares the current inputs from the recomposition with the inputs it stored from the previous composition. If they don’t match, it returns null, signaling to the rememberSaveable composable that the state should be reset to its init value. This is how rememberSaveable achieves the behavior of re-initializing when its keys change.

Use-cases

Now that you’ve explored the internals of remember, rememberSaveable, and their supporting components like SaveableStateRegistry and SaveableHolder, the next question is: When should you actually use each one?

At first glance, rememberSaveable might seem like the better option, it can persist across both recompositions and process death. So should you just use it everywhere instead of remember?

Let’s walk through some practical use cases from RevenueCat’s Android SDK to understand when each API is appropriate, and why picking the right one matters for both performance and user experience.

remember

As discussed earlier, remember preserves state across recompositions, but it does not restore values after configuration changes (e.g., rotations or process death). In RevenueCat’s Android SDK demo, we use remember with mutableStateOf to hold UI state during recomposition, like this:

1var isDebugBottomSheetVisible by remember { mutableStateOf(false) }
2var showLogInDialog by remember { mutableStateOf(false) }
3var showApiKeyDialog by remember { mutableStateOf(false) }

So those values will be re-initialized to false if a configuration change occurs, such as rotating the screen, switching to dark mode, or adjusting the system font size.

rememberSaveable

On the other hand, the RevenueCat SDK uses rememberSaveable to persist specific state not only across recompositions, but also through configuration changes, as seen in the PaywallDialog example.

1@Composable
2fun PaywallDialog(
3    paywallDialogOptions: PaywallDialogOptions,
4) {
5    val shouldDisplayBlock = paywallDialogOptions.shouldDisplayBlock
6    var shouldDisplayDialog by rememberSaveable { mutableStateOf(shouldDisplayBlock == null) }
7    if (shouldDisplayBlock != null) {
8        LaunchedEffect(paywallDialogOptions) {
9            launch {
10                shouldDisplayDialog = shouldDisplayPaywall(shouldDisplayBlock)
11            }
12        }
13    }

In this case, the paywall dialog should remain visible if the user isn’t entitled to access premium features, even after a configuration change. If you used remember instead of rememberSaveable, the shouldDisplayDialog state could be reset to false during such changes, like screen rotation, dark mode toggling, or font size adjustments, which might lead to unintended behavior, such as granting access to premium features without proper entitlement.

So for the same use cases of state to display a dialog, which one you should use can be very different on the situation. Then, does using rememberSaveable everywhere instead of remember is better since it has more features and retain better? The answer is no.

Retaining all states in every scenario can also cause unintended behavior, and as you’ve seen in the internal implementation, rememberSaveable carries more overhead than remember. That’s why it’s important to carefully choose the right API based on your specific requirements, balancing persistence needs against performance considerations.

Conclusion

In this article, you’ve covered the core concepts of Compose phases, recomposition, state, and the internal workings of remember and rememberSaveable along with guidance on selecting the right API for your needs. Together, these concepts form the cornerstone of the Compose Runtime, enabling Jetpack Compose to deliver a truly declarative UI experience.

As always, happy coding!

— Jaewoong

You might also like

Share this post