Saving UI State: Views vs. Compose

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

A screen’s UI state can disappear for three very different reasons, and the two Android UI toolkits give a developer overlapping but not identical tools to answer each one. This page lays the three events side by side, then works through every mechanism TrailMate uses to survive them — first in the legacy Views stack, then in Jetpack Compose — and closes with the persistent-storage boundary neither toolkit’s in-memory tools can cross, and how to actually test process death instead of assuming a fix works. It targets Android 17 (API level 37), lifecycle 2.11.0, Compose BOM 2026.09.00 (Compose runtime/UI 1.12.1) and Navigation 3 1.2.0, and builds directly on three pages that already introduced pieces of this story: Activities and the Activity Lifecycle (rotation, back, process death at the activity level), ViewModel, Lifecycle-Aware Components, Coroutines and Flow (exactly what a ViewModel survives and what it does not), and State, Recomposition and Side Effects in Compose (remember, rememberSaveable, state hoisting). Those pages gave each mechanism a first mention; this page is where each one gets explained in full and placed on the same map.

Two sibling pages continue directly from here: Views, Fragments and Activities vs. Compose: Architecture Comparison summarizes this page’s findings as one row of its side-by-side comparison, and Migrating from Views to Compose covers replacing onSaveInstanceState/fragment arguments with rememberSaveable/SavedStateHandle as one phase of an actual migration. Kotlin’s Parcelable/@Parcelize support itself, and the Kotlin property-delegate syntax (by savedStateHandle.saveable { }), are Kotlin-language features and not re-taught here; see Kotlin for Android for the delegate mechanics behind by.

Three Ways to Lose State

The phrase "state loss" covers three events with very different causes, very different frequencies, and — critically — very different amounts of the system’s own help in surviving them. Confusing one for another is the single most common cause of bugs in this area: a fix that only handles a configuration change silently does nothing for process death, and a fix aimed at process death is often far more machinery than a configuration change alone would need.

Event What actually happens How often it happens

Configuration change (rotation, dark/light mode toggle, font-scale change, locale change, fold/unfold, entering/leaving multi-window, a display added or removed)

The system destroys and immediately recreates the current Activity (and every Fragment it hosts) within the same process to apply the new configuration’s resources. Nothing about the process, its heap, or any singleton in it changes — only the activity/fragment/view instances are thrown away and rebuilt.

Frequent, and mostly avoidable to test manually: every rotation and every dark/light toggle triggers it.

System-initiated process death (the OS kills the app’s process to reclaim memory while the app is in the background, or the user is far enough away in time/task-switching that the process was never kept alive)

The entire process disappears — every static field, every singleton, every ViewModelStore, every in-memory cache is gone. When the user navigates back to the task, Android starts a new process and recreates the activity from scratch, but restores a Bundle it saved just before killing the old process (if the activity had a chance to call onSaveInstanceState first) so the new activity can rebuild an equivalent UI state.

Common on real devices under memory pressure, especially on lower-RAM devices, but essentially invisible during ordinary development on an emulator with plenty of RAM — which is exactly why bugs here reach production.

User dismissal (swiping the task away from Recents, "Force stop" from system settings, or the user explicitly finishing every screen back to the task’s root and beyond, i.e. the task itself is removed, not just backgrounded)

The system does not save a restoration Bundle for a task the user deliberately removed — there is nothing to restore to: the whole task, including its back stack, is gone. Reopening the app starts a completely fresh task, exactly as if it had never run.

Deliberate, user-initiated, and the one case a "restore what the user was doing" feature should usually not try to fight — see Persistent Storage: What Must Outlive the Task below for the boundary between what a task should restore automatically and what belongs in the user’s own history/favorites instead.

What survives Configuration change Process death (task kept) User dismissal (task removed)

A ViewModel instance and its in-memory fields

✅ Yes — the same instance, the same running coroutines

❌ No — a brand-new instance is created

❌ No — a brand-new instance is created

A ViewModel’s `SavedStateHandle contents

✅ Yes (trivially — the instance itself survives)

✅ Yes, if the value was written to the handle before death

❌ No — there is no Bundle to restore from

onSaveInstanceState/rememberSaveable Bundle contents

✅ Yes

✅ Yes, if written before the process was killed

❌ No

Plain remember { } state with no rememberSaveable

❌ No — the whole Activity and Composition are recreated

❌ No

❌ No

Data persisted to Room/DataStore/files

✅ Yes

✅ Yes

✅ Yes — persistent storage is the only mechanism unaffected by any of the three events

Views: ViewModel (Configuration Changes Only)

ViewModel, Lifecycle-Aware Components, Coroutines and Flow already covers ViewModel scoping and creation in full; the one fact that matters most for this page is the boundary of what it survives, repeated here because so much of the rest of this page exists to patch the gap on its other side: a ViewModel’s `ViewModelStore is retained by the framework across a configuration change (the same instance, with every in-flight viewModelScope coroutine still running), but it is destroyed along with the rest of the process’s heap on system-initiated process death. TrailListViewModel’s in-memory `selectedRegion field, if it were a plain var with no further mechanism, would survive a rotation perfectly and vanish completely the moment the process is killed — which is exactly the gap SavedStateHandle, later on this page, exists to close.

Views: onSaveInstanceState in the Activity and the Fragment

Before a configuration change destroys an Activity, and before the system kills a backgrounded process it might later need to restore, the framework calls onSaveInstanceState(Bundle) on the activity (and, separately, on each fragment through Fragment.onSaveInstanceState) to give it a chance to write small pieces of UI state into a Bundle. That Bundle is handed back to onCreate(savedInstanceState: Bundle?) (and to onRestoreInstanceState, which most code skips in favor of reading straight from `onCreate’s parameter) the next time an activity instance for that same task is created — whether that is immediately, for a rotation, or much later, for process death:

// app/src/main/kotlin/com/example/trailmate/MainActivity.kt
class MainActivity : AppCompatActivity(R.layout.activity_main) {
    private var selectedRegionFilter: String? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        selectedRegionFilter = savedInstanceState?.getString(KEY_REGION_FILTER)
        // ...
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString(KEY_REGION_FILTER, selectedRegionFilter)
    }

    private companion object {
        const val KEY_REGION_FILTER = "region_filter"
    }
}

onSaveInstanceState runs after onStop on modern API levels (it used to run before onPause on very old platform versions, a detail books written before that change still describe), which means it must never be relied on to run before the process is actually killed for a reason other than the normal lifecycle — a crash, or Process.killProcess(), skips it entirely. It is meant for small amounts of UI-only state (a filter selection, a scroll position, the exact contents of a text field the user was editing) — see [Bundle Size Limits and TransactionTooLargeException] below for why "small" is a hard requirement, not just good practice. A Fragment’s own `onSaveInstanceState(Bundle) works identically, scoped to that fragment’s own state rather than the whole activity’s. See Android Developers — Save and restore activity state and Android Developers — Fragment lifecycle.

Views: Automatic View State Saving (Requires android:id)

Beneath the activity/fragment-level onSaveInstanceState above, every individual View in the hierarchy gets an independent chance to save its own state through View.onSaveInstanceState()/onRestoreInstanceState(), which the framework calls automatically as part of the activity’s own save/restore cycle — no code in MainActivity or TrailListFragment has to call this by hand. EditText saves its text and selection this way, ScrollView and RecyclerView save their scroll offset, and CheckBox/RadioButton/Switch save their checked state, all without a single line of app code.

There is exactly one requirement for this to work: the view must have an android:id in its layout XML. Android keys each view’s saved state by its id inside a SparseArray, so a view with no id (or, worse, two sibling views that accidentally share the same id, common when the same layout is `<include>`d twice) either loses its state silently or has it overwritten by another view’s:

<!-- feature/trails/src/main/res/layout/fragment_trail_list.xml -->
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/trail_list"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
<!-- Without android:id, trail_list's scroll position resets to the top on every rotation. -->

freezesText, an attribute on TextView and its subclasses (EditText), controls exactly one thing: whether that view’s text content itself is included in its automatically saved state. It defaults to false for a plain, non-editable TextView (there is usually nothing to restore — the text comes from a resource or from code that will set it again) and to effectively true for an EditText’s user-entered text, but setting `android:freezesText="true" explicitly is worth doing on a TextView whose text is computed at runtime and not trivially recomputable on recreation, so it survives the same automatic save/restore path an EditText gets by default:

<TextView
    android:id="@+id/elevation_summary"
    android:freezesText="true"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

Views: a Custom View’s BaseSavedState

Automatic view state saving above only knows how to save the specific built-in state a stock widget defines (EditText’s text, `CheckBox’s checked flag). A custom `View — TrailMate’s ElevationProfileView, introduced on Custom Views, Touch, Drawing and View Animation — that holds its own state the framework knows nothing about (a zoom level, a selected point along the elevation profile) must override onSaveInstanceState()/onRestoreInstanceState() itself, and must be careful to also preserve the superclass’s own state rather than discarding it, since View itself has base state (like visibility) to save too. BaseSavedState (or AbsSavedState) is the framework’s helper class for exactly this chaining:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ElevationProfileView.kt
class ElevationProfileView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
    var selectedPointIndex: Int = -1

    override fun onSaveInstanceState(): Parcelable {
        val superState = super.onSaveInstanceState()
        return SavedState(superState).also { it.selectedPointIndex = selectedPointIndex }
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        if (state !is SavedState) {
            super.onRestoreInstanceState(state)
            return
        }
        super.onRestoreInstanceState(state.superState)
        selectedPointIndex = state.selectedPointIndex
    }

    @Parcelize
    private class SavedState(
        val superStateParcelable: Parcelable?,
        var selectedPointIndex: Int = -1,
    ) : BaseSavedState(superStateParcelable) {
        // A hand-written Parcelable.Creator is normally required here; @Parcelize
        // (see below) generates it, replacing the CREATOR boilerplate older code hand-writes.
    }
}

This works automatically alongside the rest of the view tree precisely because ElevationProfileView also has an android:id in its layout — the same requirement as [Views: Automatic View State Saving (Requires android:id)] above applies to a custom view’s own onSaveInstanceState() override just as much as it does to a stock widget’s built-in one. See Android Developers — AbsSavedState and Android Developers — Create a custom view.

Bundle Size Limits and TransactionTooLargeException

Every Bundle written by onSaveInstanceState (activity, fragment, or a custom view’s BaseSavedState) is ultimately serialized and passed to the system process over Binder IPC, and Binder enforces a transaction buffer limit shared across the whole process — commonly cited as roughly 1 MB, though the exact figure is an implementation detail Android does not guarantee, not a documented per-call budget. Exceeding it throws android.os.TransactionTooLargeException, and because the limit is shared across every in-flight transaction in the process, a Bundle that is comfortably under it in isolation can still tip a concurrent transaction over the edge. In practice, the failure this produces is a crash on rotation or on returning from the background that never happens in casual testing (a small Bundle) and reliably happens for one specific user (a Bundle that happens to include, say, an entire in-memory photo bitmap or a large list serialized item-by-item).

The fix is never "put slightly less in the Bundle`" — it is to keep `onSaveInstanceState/SavedStateHandle strictly to small, purely-UI-scoped values (an id, a filter string, a scroll offset, a boolean) and to route anything that resembles actual data — a list of trails, a photo, a whole domain object — through ViewModel state backed by a repository, or through persistent storage as covered in [Persistent Storage: What Must Outlive the Task] below, and re-fetch or re-derive it after recreation instead of ever trying to serialize it into a Bundle in the first place. See Android Developers — TransactionTooLargeException and Android Developers — Saving UI States — restore UI state.

@Parcelize

Any custom type placed into a Bundle — a SavedState inner class as above, or a value handed through SavedStateHandle — must implement android.os.Parcelable, whose hand-written form (a writeToParcel override plus a CREATOR companion object implementing Parcelable.Creator) is mechanical, easy to get subtly wrong (a field written in one order and read back in another), and tedious to keep in sync as a class’s fields change. The Kotlin compiler plugin kotlin-parcelize (bundled with Kotlin itself, applied as id("kotlin-parcelize") — one of the plugins built into AGP 9’s Kotlin-by-default toolchain, needing no separate version) generates all of that from a single annotation:

// core/model/src/main/kotlin/com/example/trailmate/core/model/TrailSelection.kt
@Parcelize
data class TrailSelection(
    val trailId: String,
    val scrollOffsetPx: Int,
) : Parcelable

Every property in the primary constructor is written to and read from the Parcel in declaration order automatically; a property that should not be persisted this way is excluded with @IgnoredOnParcel. This is the mechanism TrailSelection.SavedState above, and any SavedStateHandle-stored custom type later on this page, relies on. See Android Developers — Parcelable implementation generator and Kotlin Documentation for the plugin’s release history.

Fragment Arguments

A Fragment’s arguments `Bundle (set once, before the fragment is attached, via Fragment(args), fragment.arguments = bundleOf(…​), or — in TrailMate’s case — generated Safe Args classes) is a distinct mechanism from onSaveInstanceState, even though both are backed by a Bundle: arguments are immutable input the fragment was created with, not mutable state the fragment saves about itself over time. Critically, arguments survive every one of the three events in Three Ways to Lose State above for as long as the fragment itself (or its containing back-stack entry) exists, because the FragmentManager re-supplies the same arguments Bundle to a newly-recreated fragment instance automatically — there is nothing to opt into:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailFragment.kt
class TrailDetailFragment : Fragment(R.layout.fragment_trail_detail) {
    private val args: TrailDetailFragmentArgs by navArgs()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewModel.loadTrail(args.trailId) // args.trailId is available even after process death
    }
}

This is why a value that a screen was navigated to with (a trailId, not a value the user later typed or scrolled to) rarely needs its own explicit save/restore handling at all — Safe Args already round-trips it through the fragment’s arguments Bundle for free. See Android Developers — Supply arguments to a fragment and Navigation with Fragments.

SavedStateHandle: get/set, getStateFlow, the saveable Delegate

SavedStateHandle is the piece that closes the exact gap [Views: ViewModel (Configuration Changes Only)] described: a key-value map, backed by the same platform Bundle-saving machinery as onSaveInstanceState, that is handed to a ViewModel at creation time and is available for the ViewModel’s entire lifetime — meaning it survives everything a plain `ViewModel field survives (configuration changes), plus system-initiated process death, because the framework restores its backing Bundle from the very same restoration data that repopulates onCreate’s `savedInstanceState parameter. A ViewModel requests one simply by adding it as a constructor parameter (creation and injection is covered in full on ViewModel, Lifecycle-Aware Components, Coroutines and Flow):

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
@HiltViewModel
class TrailListViewModel @Inject constructor(
    private val repository: TrailRepository,
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {

    // Plain get()/set(): read once, write on every change.
    var searchQuery: String
        get() = savedStateHandle.get<String>(KEY_SEARCH_QUERY) ?: ""
        set(value) = savedStateHandle.set(KEY_SEARCH_QUERY, value)

    // getStateFlow(): a reactive view of one key, for driving UI state directly.
    val difficultyFilter: StateFlow<Difficulty?> =
        savedStateHandle.getStateFlow(KEY_DIFFICULTY_FILTER, initialValue = null)

    fun onDifficultyFilterChanged(difficulty: Difficulty?) {
        savedStateHandle[KEY_DIFFICULTY_FILTER] = difficulty
    }

    private companion object {
        const val KEY_SEARCH_QUERY = "search_query"
        const val KEY_DIFFICULTY_FILTER = "difficulty_filter"
    }
}

get(key)/set(key, value) (and the [] operator, which is set under the hood) are the imperative form, suited to a value read once at the point it is needed; getStateFlow(key, initialValue) returns a StateFlow that emits every time the key’s value changes, whether the change came from this ViewModel’s own `set call or — less commonly — from the handle being restored with a new value after process death, making it the natural choice for a value that should drive TrailListUiState reactively rather than be polled.

For a value read and written frequently enough that the raw key-string API becomes noise, the saveable delegate wraps the same handle behind ordinary Kotlin property syntax. It is the bridge between SavedStateHandle and Compose’s Saver API, so it lives in androidx.lifecycle:lifecycle-viewmodel-compose (not in lifecycle-viewmodel-savedstate), is marked @SavedStateHandleSaveableApi (an opt-in), and its var form requires the initializer to return a Compose MutableState — the delegate saves and restores that state object, and the property reads and writes its value:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
@OptIn(SavedStateHandleSaveableApi::class)
var onlyFavorites: Boolean by savedStateHandle.saveable { mutableStateOf(false) }

saveable { false }, with a plain value instead of a MutableState, does not compile as a var delegate: the overload that returns a plain value produces a read-only delegate, for a val holding a saveable state object such as a LazyListState. Because the property is backed by a snapshot MutableState, a composable that reads viewModel.onlyFavorites recomposes when it changes, with no StateFlow in between. The trade-off is a Compose runtime dependency in the ViewModel’s module; a `ViewModel shared with a Views-only UI that should not depend on Compose sticks to get/set and getStateFlow above. See Android Developers — androidx.lifecycle.viewmodel.compose (SavedStateHandle.saveable).

Navigation arguments populate SavedStateHandle automatically — in Safe Args and Navigation Compose. Just as a fragment’s arguments Bundle is supplied by the FragmentManager, a NavController-based destination’s arguments (Safe Args in the legacy Views stack, or a @Serializable route’s fields in Navigation Compose) are written into the SavedStateHandle of a ViewModel scoped to that back-stack entry before the ViewModel is constructed. In Navigation Compose, TrailDetailViewModel reads its trailId with no separate wiring at all:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailViewModel.kt (Navigation Compose)
@HiltViewModel
class TrailDetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val repository: TrailRepository,
) : ViewModel() {
    private val trailId: String = savedStateHandle.toRoute<TrailDetail>().trailId
    // trailId survives process death exactly like any other SavedStateHandle-backed value --
    // it is re-populated from the restored NavBackStackEntry, not re-navigated to.
}

Navigation 3 does not do this. SavedStateHandle.toRoute<T>() is a Navigation Compose API, and Navigation 3’s rememberViewModelStoreNavEntryDecorator() gives each entry its own ViewModelStore but does not copy the NavKey’s fields into the `ViewModel’s `SavedStateHandle, so savedStateHandle.toRoute<TrailDetail>() in a Navigation 3 app fails at runtime because the arguments are simply not there. In Navigation 3 the key is already in hand inside entry<TrailDetail> { key → …​ }, so it is passed into the ViewModel explicitly — with Hilt, through an assisted factory and hiltViewModel(creationCallback = …​):

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailViewModel.kt (Navigation 3)
@HiltViewModel(assistedFactory = TrailDetailViewModel.Factory::class)
class TrailDetailViewModel @AssistedInject constructor(
    @Assisted private val trailId: String,
    private val repository: TrailRepository,
) : ViewModel() {
    @AssistedFactory
    interface Factory {
        fun create(trailId: String): TrailDetailViewModel
    }
}

// In the NavDisplay entryProvider:
entry<TrailDetail> { key ->
    val viewModel = hiltViewModel<TrailDetailViewModel, TrailDetailViewModel.Factory>(
        creationCallback = { factory -> factory.create(key.trailId) },
    )
    TrailDetailScreen(viewModel = viewModel)
}

trailId still survives process death here, by a different route: rememberNavBackStack saves the @Serializable TrailDetail key itself, so after the process is recreated the restored entry hands the same key to the entry<TrailDetail> block, which creates a new TrailDetailViewModel with it. Either way, there is normally no need to also copy a navigation argument into a hand-written get/set key — reading it with toRoute<T>() (Navigation Compose), the Safe Args-generated property (Views), or the Navigation 3 key passed in at creation is enough. See Android Developers — Saved State module for ViewModel, Android Developers — Navigation 3 — Save and restore state and Dependency Injection with Hilt, Dagger and Koin for assisted injection in general.

Compose: remember vs. rememberSaveable

State, Recomposition and Side Effects in Compose already introduced this distinction; restated here in the vocabulary of Three Ways to Lose State above: remember { } keeps a value alive across recomposition only — it is discarded the moment the composable leaves the Composition, which happens for every one of the three state-loss events, since all three destroy and recreate the hosting Activity. rememberSaveable { } additionally writes its value into the same Bundle-based restoration mechanism onSaveInstanceState uses, so it survives a configuration change and system-initiated process death, exactly like the Views-side mechanisms above — and, exactly like them, does not survive user dismissal, since there is no Bundle to restore from once the task itself is gone.

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
var searchQuery by rememberSaveable { mutableStateOf("") }
// Rotation and process death: searchQuery is restored.
// User swipes TrailMate away from Recents: searchQuery is gone, exactly like onSaveInstanceState would be.

rememberSaveable works directly for the types Bundle already understands (primitives, String, Parcelable/@Parcelize classes) and for mutableStateOf/the primitive mutableIntStateOf-style variants wrapping one of those types, since Compose ships built-in Saver support for its own state holder types. See Android Developers — Save UI State in Compose.

Saver, listSaver and mapSaver

A type rememberSaveable does not natively know how to save — one with no @Parcelize annotation, or a plain class that is not Parcelable at all — needs a Saver<Original, Saveable>, an object with two functions: save converts the real value into something Bundle-compatible (a primitive, a String, a List/Map of those, or another already-saveable type), and restore converts it back. rememberSaveable’s `saver parameter accepts one directly:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoCaptureScreen.kt
data class DraftPhoto(val caption: String, val uri: String)

val DraftPhotoSaver = Saver<DraftPhoto, List<String>>(
    save = { listOf(it.caption, it.uri) },
    restore = { DraftPhoto(caption = it[0], uri = it[1]) },
)

@Composable
fun PhotoCaptureScreen() {
    var draftPhoto by rememberSaveable(stateSaver = DraftPhotoSaver) {
        mutableStateOf(DraftPhoto(caption = "", uri = ""))
    }
    // draftPhoto.caption survives rotation and process death while the photo is still being captioned.
}

listSaver and mapSaver are convenience builders for the two most common saveable shapes, avoiding a hand-written Saver object for a simple case:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoCaptureState.kt
val DraftPhotoListSaver: Saver<DraftPhoto, *> = listSaver(
    save = { listOf(it.caption, it.uri) },
    restore = { DraftPhoto(caption = it[0], uri = it[1]) },
)

val DraftPhotoMapSaver: Saver<DraftPhoto, *> = mapSaver(
    save = { mapOf("caption" to it.caption, "uri" to it.uri) },
    restore = { DraftPhoto(caption = it["caption"] as String, uri = it["uri"] as String) },
)

listSaver is positional and slightly smaller on the wire; mapSaver is self-documenting and more resilient to a field being reordered later, at the cost of string keys that can drift out of sync if renamed carelessly. Both still ultimately serialize into a Bundle, so the same size discipline from [Bundle Size Limits and TransactionTooLargeException] applies just as much to a Saver-backed rememberSaveable value as it does to a Views-side onSaveInstanceState Bundle. See Android Developers — Saver, listSaver, mapSaver reference.

Saveable Scroll States

LazyListState/LazyGridState (from rememberLazyListState(), used by TrailMate’s TrailListScreen) and ScrollState (from rememberScrollState(), used for a single-column detail screen) are already saveable by construction — both types ship their own built-in Saver, wired transparently into the remember…​State() factory functions themselves, so no explicit rememberSaveable call or custom Saver is needed to survive a configuration change or process death:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@Composable
fun TrailListContent(trails: List<Trail>, modifier: Modifier = Modifier) {
    val listState = rememberLazyListState() // scroll position survives rotation and process death automatically
    LazyColumn(state = listState, modifier = modifier) {
        items(trails, key = { it.id }) { trail -> TrailListItem(trail) }
    }
}

This is the Compose-side counterpart to [Views: Automatic View State Saving (Requires android:id)]'s RecyclerView scroll position — both survive automatically, but through opposite mechanisms: the View side needs a stable android:id on the RecyclerView itself, while rememberLazyListState() needs nothing beyond being called through remember (transitively rememberSaveable) at all, since the state object it returns is already Saver-aware. See Android Developers — rememberLazyListState().

SaveableStateHolder

rememberSaveable alone only ever remembers one value per call site in the Composition — it has no concept of "this same composable, but shown for a different item," which matters the moment several equivalent screens share one composable function, such as a LazyColumn row that is removed and later re-added when the user scrolls away and back, or a tab layout that disposes an off-screen tab’s content entirely. SaveableStateHolder (obtained via rememberSaveableStateHolder()) solves this by keying an entire subtree’s saved state to an explicit id, so that state saved under one key is preserved even while that subtree’s composables are temporarily removed from the Composition and later reintroduced:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailTabsScreen.kt
@Composable
fun TrailTabsScreen(selectedTab: TrailTab) {
    val saveableStateHolder = rememberSaveableStateHolder()

    saveableStateHolder.SaveableStateProvider(key = selectedTab) {
        when (selectedTab) {
            TrailTab.List -> TrailListScreen(onTrailClick = {})
            TrailTab.Map -> TrailMapScreen()
        }
    }
    // Switching away from TrailTab.List and back later restores its rememberSaveable
    // state (scroll position, search query) even though its composables were disposed
    // in between, because SaveableStateProvider kept that key's saved values around.
}

This is precisely the mechanism Navigation Compose and Navigation 3 build on internally to preserve each back-stack entry’s rememberSaveable state while its composables are off-screen — covered next. See Android Developers — rememberSaveableStateHolder().

Navigation 3 Entry Decorators

Navigation in Compose (Navigation Compose and Navigation 3) already introduced Navigation 3’s own back stack (NavKey, rememberNavBackStack) and NavDisplay; the piece relevant here is entry decorators, the mechanism that wraps each NavDisplay entry with exactly the SaveableStateHolder/ViewModelStore scoping described above, opted into per NavDisplay rather than baked in implicitly:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailNavHost.kt
@Composable
fun TrailNavHost(backStack: NavBackStack) {
    NavDisplay(
        backStack = backStack,
        entryDecorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator(),
            rememberViewModelStoreNavEntryDecorator(),
        ),
        entryProvider = entryProvider {
            entry<TrailList> { TrailListScreen(onTrailClick = { /* ... */ }) }
            entry<TrailDetail> { key -> TrailDetailScreen(trailId = key.trailId) }
        },
    )
}

// The keys themselves: @Serializable NavKeys, so rememberNavBackStack can save them.
@Serializable data object TrailList : NavKey
@Serializable data class TrailDetail(val trailId: String) : NavKey

rememberSaveableStateHolderNavEntryDecorator() gives every entry the same per-key rememberSaveable preservation SaveableStateHolder provides above, keyed by each entry’s content key (derived from its NavKey), so TrailListScreen’s `rememberSaveable search query and scroll position survive navigating to TrailDetailScreen and back, not just a configuration change while TrailListScreen itself stays on screen. rememberViewModelStoreNavEntryDecorator() does the equivalent for ViewModel`s obtained via `hiltViewModel() inside an entry, giving each back-stack entry its own ViewModelStore that survives exactly as long as that entry remains on the stack (it comes from androidx.lifecycle:lifecycle-viewmodel-navigation3, and, as covered above, it does not put the key’s fields into that ViewModel’s `SavedStateHandle). The back stack itself survives configuration changes and process death because rememberNavBackStack saves it, which is why every key must be a @Serializable NavKey — Parcelable is not what Navigation 3 uses. Both decorators are opt-in per NavDisplay precisely because Navigation 3, unlike Navigation Compose’s NavHost, does not assume a single fixed shape for every app’s back stack. See Android Developers — Navigation 3 basics and Android Developers — Navigation 3 — Save and restore state.

Persistent Storage: What Must Outlive the Task

Every mechanism above — onSaveInstanceState, SavedStateHandle, rememberSaveable — shares one boundary: none of them survive user dismissal, and all of them are meant for small amounts of transient, screen-scoped UI state, not for data the app considers the user’s own, which must remain available even after the task is removed, the app is force-stopped, or the device reboots. TrailMate draws this line at the data layer already introduced on Recommended App Architecture (UI, Domain and Data Layers): a favorited trail, a downloaded trail’s cached details, or a user preference such as measurement units belongs in Room or DataStore (covered fully on Local Storage: DataStore, Files, Scoped Storage and MediaStore and Room Database and Paging) rather than in any of this page’s in-memory mechanisms, precisely because it must survive all three events in [Three Ways to Lose State], including the one none of them cover.

Belongs in rememberSaveable/SavedStateHandle Belongs in Room/DataStore

The current search query, not yet submitted anywhere

A saved search the user explicitly bookmarked

A scroll position or the currently selected tab

The list of favorited trails itself

A draft photo caption, mid-edit, before the photo is saved

The photo and caption once the user taps "Save"

Which filter chip is currently toggled on this screen

The user’s persisted default filter preference (UserPreferencesRepository, DataStore-backed)

The practical rule this table encodes: if losing the value when the user swipes the app away from Recents would be surprising to the user (they would expect their favorite trails, or their saved settings, to still be there next launch), it belongs in persistent storage; if losing it would be unsurprising, or even expected (an in-progress search the user never submitted), rememberSaveable/SavedStateHandle is the right and sufficient tool, and reaching for Room/DataStore for it only adds I/O and schema surface for no real durability gain.

Pitfalls of android:configChanges

The manifest attribute android:configChanges (declared per activity, e.g. android:configChanges="orientation|screenSize") tells the system that this activity itself will handle the listed configuration changes, which suppresses the destroy-and-recreate cycle entirely for those changes and instead delivers them to Activity.onConfigurationChanged(Config) while the same activity instance keeps running. It looks, at first glance, like a shortcut around everything on this page — if the activity is never destroyed, there is nothing to save or restore.

In practice it trades one problem for a worse one: onConfigurationChanged only reports the configuration change itself; it does not re-run resource resolution for anything already inflated, so any view, string, or drawable that should change for the new configuration (a different layout for landscape, a differently-qualified drawable, a locale-specific string) needs to be manually re-applied in code that duplicates what the framework’s normal resource system already does for free. It also does not compose with several modern APIs cleanly — window size class recalculation and adaptive layout changes (Adaptive Layouts, Edge-to-Edge and Large Screens) generally assume the normal recreate cycle — and it does nothing whatsoever for system-initiated process death, which happens regardless of what android:configChanges declares, so an app that reaches for it as a state-persistence shortcut still needs SavedStateHandle/onSaveInstanceState for process death anyway, on top of the resource-handling code android:configChanges now demands. The recommended approach, restated from Resources, Configuration, Localization and Themes, remains: let the activity recreate normally, and make that recreation cheap and correct through the mechanisms this page covers, rather than suppressing it. See Android Developers — Handle the configuration change yourself (the section explicitly framing this as the exception, not the default).

Testing Process Death

Because system-initiated process death is invisible during ordinary development — an emulator with generous RAM essentially never kills a foregrounded-then-backgrounded app the way a real, memory-constrained device does — a SavedStateHandle/rememberSaveable gap is exactly the kind of bug that survives to production undetected without deliberately forcing the condition.

adb shell am kill <package> kills the app’s process directly, as if the system had reclaimed it, without removing the task from Recents — reopening TrailMate from Recents (not launching it fresh) afterward exercises exactly the process-death-with-task-kept path from Three Ways to Lose State:

adb shell am kill com.example.trailmate

The command only kills the process when TrailMate is in the background (press Home first); am kill refuses to kill a foreground app. Reopening from Recents then starts a new process in which every singleton, static field and in-memory cache is empty — the real test of whether the screen rebuilds itself from SavedStateHandle, rememberSaveable and persistent storage alone.

Developer options → Don’t keep activities is a narrower, complementary tool, and it does not kill the process. With it enabled, the system destroys each activity as soon as the user leaves it, then recreates it from its saved instance state on return. That exercises onSaveInstanceState, rememberSaveable and SavedStateHandle restoration on every navigation (the ViewModel is cleared too, because the activity is finishing rather than changing configuration), but the process and everything in it — singletons, static state, an in-memory repository cache, a Hilt @Singleton — survives. A bug where a screen silently depends on such a singleton still being populated passes this test and still fails after real process death, so Don’t keep activities is a quick check for save/restore wiring, not a substitute for adb shell am kill. See Android Developers — Test your app’s process death handling and Android Developers — Android Debug Bridge (adb).

ActivityScenario.recreate(), from androidx.test:core (used inside a JUnit test with ActivityScenarioRule/launchActivity<T>()), simulates a configuration change programmatically — it destroys and recreates the activity under test in-process, exercising onSaveInstanceState/onCreate exactly as a real rotation would, without needing a device rotation gesture or an actual configuration change at all:

// app/src/androidTest/kotlin/com/example/trailmate/MainActivityStateTest.kt
@Test
fun searchQuery_survivesRecreation() {
    ActivityScenario.launch(MainActivity::class.java).use { scenario ->
        scenario.onActivity { it.setSearchQuery("switchbacks") }
        scenario.recreate()
        scenario.onActivity { activity ->
            assertThat(activity.currentSearchQuery()).isEqualTo("switchbacks")
        }
    }
}

Note that recreate() exercises the configuration-change path specifically (the same process, a fresh activity instance) and does not by itself prove process-death survival, since SavedStateHandle’s value would already be trivially present in a `ViewModel instance that was never destroyed in the first place; it is the right tool for verifying onSaveInstanceState/rememberSaveable round-trip correctly, while adb shell am kill above is what actually exercises process death (Don’t keep activities sits in between: saved state is restored into fresh activities, but the process survives).

StateRestorationTester, from androidx.compose.ui:ui-test, is the Compose-specific equivalent for a composeTestRule-based test: it simulates saving and restoring Compose state (everything rememberSaveable covers) without tearing down and recreating a whole Activity, which makes it fast enough to use routinely in a Compose UI test suite rather than reserved for a slower instrumented ActivityScenario test:

// feature/trails/src/androidTest/kotlin/com/example/trailmate/feature/trails/TrailSearchBarTest.kt
@get:Rule val composeTestRule = createComposeRule()

@Test
fun searchQuery_survivesStateRestoration() {
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { TrailSearchBar(onQueryChange = {}) }

    composeTestRule.onNodeWithText("Search trails").performTextInput("switchbacks")
    stateRestorationTester.emulateSavedInstanceStateRestore()

    composeTestRule.onNodeWithText("switchbacks").assertExists()
}

SavedStateHandle unit tests need none of the above at all: SavedStateHandle() (the no-argument constructor) creates a plain in-memory instance suitable for a fast local JUnit test of a `ViewModel’s logic, with no Android framework, emulator, or instrumentation involved:

// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/TrailListViewModelTest.kt
@Test
fun `difficulty filter change is written to SavedStateHandle`() {
    val savedStateHandle = SavedStateHandle()
    val viewModel = TrailListViewModel(FakeTrailRepository(), savedStateHandle)

    viewModel.onDifficultyFilterChanged(Difficulty.HARD)

    assertThat(savedStateHandle.get<Difficulty>("difficulty_filter")).isEqualTo(Difficulty.HARD)
}

This is the cheapest and fastest of the four techniques, and the one to reach for first for any logic that merely reads and writes a SavedStateHandle key correctly — ActivityScenario.recreate() and Don’t keep activities remain necessary for confirming the activity destroy-and-recreate round-trip, and adb shell am kill for the real process-death round-trip, neither of which a unit test, by design, ever exercises. See Unit Testing with JUnit, Mockito, MockK and Robolectric for FakeTrailRepository and this test’s surrounding conventions.

Comparison Table: Views vs. Compose, by State Kind

The table below is this page’s single most useful reference: for each kind of state a screen commonly holds, the mechanism each toolkit reaches for, and how the two compare.

State kind Legacy Views mechanism Compose mechanism Notes

Scroll position

Automatic View state saving (RecyclerView, ScrollView) — requires android:id

rememberLazyListState()/rememberScrollState() — saveable automatically, no id needed

Both survive a configuration change and process death; Compose’s version needs no manifest/layout attribute at all, only that remember`ing the state object happen (transitively) through `rememberSaveable

Text input

Automatic View state saving (EditText’s text/selection) — requires `android:id; freezesText for a computed TextView

rememberSaveable { mutableStateOf("") } on the backing String state

Equivalent outcome from different plumbing: the View side is opt-out (works unless the id is missing), the Compose side is opt-in (remember vs. rememberSaveable)

Selection (a filter chip, a selected list item, a radio choice)

onSaveInstanceState (activity/fragment) for a filter not tied to a specific widget’s automatic state; automatic state saving for RadioButton/CheckBox/Switch directly

rememberSaveable { mutableStateOf(…​) }, or a Saver/listSaver/mapSaver for a non-primitive selection value such as TrailSelection

Both need @Parcelize/a Saver once the selected value is a custom type rather than a primitive or enum

Whole-screen UI state (a full TrailListUiState-shaped object: loading flag, list, error)

Not saved directly — reconstructed from ViewModel/repository state after recreation, since it is rarely small enough for a Bundle ([Bundle Size Limits and TransactionTooLargeException])

Same approach: TrailListUiState lives in the ViewModel’s `StateFlow, re-derived from SavedStateHandle-backed inputs (query, filter) plus a repository re-fetch, not saved as one object

Identical strategy in both stacks — this is exactly why Persistent Storage: What Must Outlive the Task's advice ("small UI state in the handle, real data from the repository") applies equally to both

Navigation back stack

nav_graph.xml’s `NavHostFragment/FragmentManager back stack — saved and restored automatically as part of the activity’s own onSaveInstanceState

Navigation Compose’s NavHost back stack is saved automatically the same way; Navigation 3’s NavBackStack (via rememberNavBackStack) needs its NavKey`s to be `@Serializable for the same automatic restoration to apply

Both frameworks handle this without app code in the common case; Navigation 3’s app-owned back stack (see Navigation in Compose) makes the requirement (@Serializable NavKey types) explicit rather than implicit, and passes each key’s arguments to its ViewModel explicitly rather than through SavedStateHandle

Business/domain data (the trail list itself, favorited trails, user preferences)

Room/DataStore, via OfflineFirstTrailRepository/UserPreferencesRepository — unaffected by any of the three state-loss events

Identical: the same Room/DataStore-backed repositories, consumed through the same TrailRepository interface

No difference between the two UI stacks at all — this row lives entirely below the UI layer; see Recommended App Architecture

A grid figure with three columns labeled 'Configuration change', 'Process death (task kept)' and 'User dismissal (task removed)', and six rows labeled 'ViewModel in-memory field', 'SavedStateHandle / onSaveInstanceState / rememberSaveable', 'Automatic View state / saveable scroll state (needs android:id or a built-in Saver)', 'Plain remember (no rememberSaveable)', 'Navigation back stack', and 'Room / DataStore (persistent storage)'. Each cell is marked with a green checkmark for 'survives' or a red cross for 'lost'. The ViewModel row shows checkmark, cross, cross. The SavedStateHandle/onSaveInstanceState/rememberSaveable row shows checkmark, checkmark, cross. The automatic View state / saveable scroll state row shows checkmark, checkmark, cross. The plain remember row shows cross, cross, cross. The navigation back stack row shows checkmark, checkmark, cross. The Room/DataStore row shows checkmark, checkmark, checkmark — the only row with three checkmarks, visually called out with a highlighted background band and the caption 'the only mechanism unaffected by all three events'. A small legend below the grid restates the checkmark and cross meanings for readers relying on shape/color-independent cues.

TrailMate End to End: the Same Four Values, Both Stacks

To make the table concrete, here is exactly where TrailMate’s TrailListScreen/TrailListFragment puts its four most common pieces of transient UI state, in both stacks, side by side.

// --- Shared by both stacks: TrailListViewModel.kt ---
@OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel
class TrailListViewModel @Inject constructor(
    private val repository: TrailRepository,
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
    // 1. Search query -- small String, SavedStateHandle via the saveable delegate (backed by a MutableState).
    var searchQuery: String by savedStateHandle.saveable { mutableStateOf("") }

    // 2. Difficulty filter -- small enum, SavedStateHandle, reactive.
    val difficultyFilter: StateFlow<Difficulty?> =
        savedStateHandle.getStateFlow(KEY_DIFFICULTY, initialValue = null)

    fun onDifficultyFilterChanged(difficulty: Difficulty?) {
        savedStateHandle[KEY_DIFFICULTY] = difficulty
    }

    // 4. Draft photo caption -- small String, mid-edit, SavedStateHandle (used by the Views screen).
    var draftCaption: String by savedStateHandle.saveable { mutableStateOf("") }

    // Whole-screen state -- derived from the saved inputs plus the repository, never saved itself.
    val uiState: StateFlow<TrailListUiState> = combine(
        repository.trails,
        snapshotFlow { searchQuery },
        difficultyFilter,
    ) { trails, query, difficulty ->
        val visible = trails.filter { trail ->
            trail.name.contains(query, ignoreCase = true) && (difficulty == null || trail.difficulty == difficulty)
        }
        TrailListUiState.Success(trails = visible, onlyFavorites = false) as TrailListUiState
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TrailListUiState.Loading)

    private companion object { const val KEY_DIFFICULTY = "difficulty_filter" }
}
<!-- 3. Scroll position -- automatic View state saving, requires android:id. -->
<!-- feature/trails/src/main/res/layout/fragment_trail_list.xml -->
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/trail_list"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
// --- Compose: TrailListScreen.kt ---
@Composable
fun TrailListScreen(
    onTrailClick: (String) -> Unit,
    viewModel: TrailListViewModel = hiltViewModel(), // same ViewModel class as above, unchanged
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val difficultyFilter by viewModel.difficultyFilter.collectAsStateWithLifecycle() // 2.

    // 3. Scroll position -- saveable automatically, no android:id equivalent needed.
    val listState = rememberLazyListState()

    Column {
        // 1. Search query -- read and written straight through the ViewModel property. It is snapshot
        // state, so typing recomposes, and no local copy exists that could drift out of sync.
        OutlinedTextField(
            value = viewModel.searchQuery,
            onValueChange = { viewModel.searchQuery = it },
            label = { Text("Search trails") },
        )
        DifficultyFilterChips(selected = difficultyFilter, onSelect = viewModel::onDifficultyFilterChanged)
        val trails = (uiState as? TrailListUiState.Success)?.trails.orEmpty()
        LazyColumn(state = listState) {
            items(trails, key = { it.id }) { trail -> TrailListItem(trail, onClick = { onTrailClick(trail.id) }) }
        }
    }
}

// --- Compose: PhotoCaptureScreen.kt ---
@Composable
fun PhotoCaptureScreen() {
    // 4. Draft photo caption -- rememberSaveable, purely UI-local until the photo is saved.
    var draftCaption by rememberSaveable { mutableStateOf("") }
    OutlinedTextField(value = draftCaption, onValueChange = { draftCaption = it }, label = { Text("Caption") })
}

The search query and difficulty filter reuse the exact same TrailListViewModel/SavedStateHandle code in both stacks (the Compose screen reads and writes viewModel.searchQuery directly rather than copying it into a local remember { mutableStateOf(viewModel.searchQuery) }, which would be a one-time snapshot that is never written back, so the query would revert on the next rotation) — this is one of the practical payoffs of TrailMate’s Views and Compose UIs sharing a ViewModel layer, covered further on Migrating from Views to Compose. Only the scroll position and the draft caption differ in mechanism: the scroll position moves from an android:id`ed `RecyclerView to a saveable-by-construction LazyListState, and the draft caption — deliberately kept as purely local Compose UI state here rather than promoted to the ViewModel, per [Persistent Storage: What Must Outlive the Task]'s guidance on state that is unsurprising to lose — moves from the Views screen’s SavedStateHandle-backed draftCaption property to an explicit rememberSaveable in PhotoCaptureScreen.

References