Mobile Development 18 min read

Compose State Management: When to Use ViewModel vs remember vs rememberSaveable

The article distinguishes three lifecycle events that reset UI state — recomposition, configuration change, and process death — and maps each to the appropriate Compose state holder (remember, retain, rememberSaveable, ViewModel) with a counter example, showing how to persist user-modified data and enable JVM unit testing.

AndroidPub
AndroidPub
AndroidPub
Compose State Management: When to Use ViewModel vs remember vs rememberSaveable

Three Kinds of "Reset to Zero"

The author begins with a simple counter screen where the count is held in a remember { mutableIntStateOf(0) } inside a composable. This works while the user stays on the screen and the UI recomposes. However, two distinct failures appear:

Rotating the device resets the count to 0.

After the process is killed in the background and the user returns from recents, the count is also 0.

The root cause is that remember only survives recomposition within the same composition. It does not survive configuration changes (which destroy and recreate the Activity and the entire composition tree) nor process death (which wipes all memory).

Classifying the Three Lifetimes

The article defines three distinct "reset to zero" scenarios:

Recomposition — Compose re-runs the composable function. remember retains the value across recompositions.

Configuration change — Rotation, theme change, language change. The Activity is destroyed and recreated; the composition tree is new, so remember slots are lost. Objects stored in a longer-lived scope (e.g., ViewModel) survive.

Process death — The OS kills the process to reclaim memory. All in-memory objects (including ViewModel instances) are gone. Only a small Bundle saved by the system can be restored.

A diagram (referenced but not included in the HTML) illustrates these three lifetimes side by side.

Decision Guide: Which Tool for Which Lifetime

Only UI recomposition — Use remember.

Rotation, theme, language change — no unit test needed — Use retain.

Small serializable details that must survive process death — Use rememberSaveable.

User-modified data that must survive and be unit-testable — Use ViewModel, with small fields placed into SavedStateHandle.

Process-scoped only (e.g., stopwatch) — Keep in memory, do not put in handle.

Moving the Counter into ViewModel

The author refactors the counter into a CounterViewModel that receives a SavedStateHandle in its constructor. The count is exposed as a StateFlow<Int> backed by saved.getStateFlow(COUNT, 0). Increment and decrement read the current value via saved.get<Int>(COUNT) ?: 0 and write back to saved[COUNT].

class CounterViewModel(
    private val saved: SavedStateHandle
) : ViewModel() {
    val count: StateFlow<Int> = saved.getStateFlow(COUNT, 0)

    fun increment() {
        val current = saved.get<Int>(COUNT) ?: 0
        saved[COUNT] = current + 1
    }

    fun decrement() {
        val current = saved.get<Int>(COUNT) ?: 0
        saved[COUNT] = current - 1
    }

    companion object {
        const val COUNT = "count"
    }
}

The composable CounterRoute obtains the ViewModel via viewModel(), collects the count with collectAsStateWithLifecycle(), and forwards clicks to viewModel.increment().

Testing Without UI

Because the logic lives in the ViewModel, a plain JVM test can instantiate CounterViewModel with a SavedStateHandle pre-populated with a map (e.g., mapOf("count" to 4)), call increment(), and assert that the handle now holds 5. No emulator, no UI interaction, fast and deterministic.

@Test
fun increment_updatesSavedCount() {
    val handle = SavedStateHandle(mapOf("count" to 4))
    val viewModel = CounterViewModel(handle)
    viewModel.increment()
    assertEquals(5, handle.get<Int>("count"))
}

Process-Scoped State: Stopwatch Example

The same screen also shows a stopwatch ("seconds since this process started"). This value describes only the current process lifetime; it should reset after process death. Therefore it stays in a plain mutableIntStateOf inside the ViewModel (not in SavedStateHandle). After rotation the stopwatch continues (ViewModel survives), after process death it restarts from 0.

Why ViewModel Survives Configuration Changes

ViewModel instances are held by a ViewModelStore attached to the ViewModelStoreOwner (Activity or Navigation graph entry). When the Activity is recreated, the same store is retained, so the same ViewModel instance is returned. Fields kept only in the ViewModel (not in SavedStateHandle) survive configuration changes but not process death.

Finer-Grained ViewModel Scopes with Lifecycle 2.11

For screens with multiple independent regions (e.g., a horizontal pager), a single page-level ViewModel may be too coarse. Lifecycle 2.11 introduces ViewModelStoreProvider, rememberViewModelStoreProvider, and rememberViewModelStoreOwner to create a nested store tied to a specific composable invocation. Each page in the pager gets its own ViewModelStoreOwner keyed by the page index. The store is cleared when that page leaves the composition tree, and its ViewModels are cleaned up (coroutines cancelled).

val provider = rememberViewModelStoreProvider()
val pagerState = rememberPagerState(pageCount = 5)
HorizontalPager(state = pagerState) { page -
    val storeOwner = rememberViewModelStoreOwner(provider = provider, key = page)
    CompositionLocalProvider(LocalViewModelStoreOwner provides storeOwner) {
        val viewModel: PageViewModel = viewModel()
        PageBody(viewModel)
    }
}

For full-page ViewModels in Navigation 3, rememberViewModelStoreNavEntryDecorator() is added to NavDisplay.entryDecorators alongside rememberSaveableStateHolderNavEntryDecorator().

Reimplementing ViewModel Mechanics Manually (Not Recommended)

The article demonstrates what it takes to avoid ViewModel by wiring the same boundaries yourself: retain with a unique key to survive configuration changes. LocalSaveableStateRegistry to read/write a Bundle for process death (only primitive values).

Implement RetainObserver and override onRetired to cancel coroutines (equivalent to viewModelScope cancellation).

Use Navigation 3's NavEntryDecorator to bind the retained object to a back-stack entry so it is cleared when the page is popped.

A full illustrative implementation of SessionCounter (a stand-in for a custom retained object) is provided, showing the four steps. The author warns this is not production-ready — key collisions, duplicate save registrations, missing decorators, and overlapping rotation/pop scenarios must be tested manually.

// Illustrative only, not a platform API.
class SessionCounter : RetainObserver {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    var count by mutableIntStateOf(0)
        private set

    fun increment() { count += 1 }
    fun restore(saved: Int) { count = saved }

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

@Composable
fun sessionCounter(key: String): SessionCounter {
    val registry = LocalSaveableStateRegistry.current
    val holder = retain(key) {
        val bundle = registry?.consumeRestored(key)
        val saved = bundle as? Bundle
        val created = SessionCounter()
        val initial = saved?.getInt(key)
        if (initial != null) created.restore(initial)
        created
    }
    DisposableEffect(registry, key, holder) {
        val entry = registry?.registerProvider(key) {
            Bundle().also { it.putInt(key, holder.count) }
        }
        onDispose { entry?.unregister() }
        holder
    }
}

To simulate process death during development: adb shell am kill <package-name>.

Dependency Injection Brings You Back to ViewModel

Once the custom retained class needs dependencies (repository, clock), it can no longer be created without parameters. Koin or Hilt must provide it, and you must decide the creation scope, when the Bundle is available, and who cancels coroutines on pop. ViewModelProvider.Factory and CreationExtras exist precisely to solve these problems. With Hilt, annotate the ViewModel with @HiltViewModel and its constructor with @Inject; SavedStateHandle is supplied automatically. The composable then uses hiltViewModel().

@HiltViewModel
class CounterViewModel @Inject constructor(
    private val saved: SavedStateHandle
) : ViewModel() { ... }

@Composable
fun CounterRoute(viewModel: CounterViewModel = hiltViewModel()) { ... }

Koin requires similar scope configuration. The author concludes that rebuilding this machinery manually recreates ViewModel with higher maintenance cost; for ordinary apps, using the battle-tested lifecycle-viewmodel library is preferable.

Core Takeaways

Recomposition only — remember is enough.

Configuration change — retain or ViewModel retains the instance; remember fails.

Process death — Only a small Bundle survives. Put small fields in SavedStateHandle (via ViewModel) or use rememberSaveable (serializable values only, logic stays in UI).

User-modified, testable data — Put in ViewModel. Process-scoped data (stopwatch) stays in memory.

Avoiding ViewModel — You must manually wire retain, Bundle save/restore, RetainObserver.onRetired, and Navigation decorators. Adding DI brings you back to ViewModel's complexity.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

ViewModelAndroidState ManagementTestingLifecycleJetpack ComposeSavedStateHandlerememberrememberSaveableretain
AndroidPub
Written by

AndroidPub

Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.