Skip to content
All posts
August 21, 20265 min read

Preserving State in Compose Amidst Configuration Changes

I walk through the pitfalls of Compose state when the device rotates, show how to use rememberSaveable, ViewModel, and lifecycle owners to keep UI data intact, and provide real code snippets and tables for quick reference.

AndroidKotlinJetpack ComposeArchitecture
Share:

Hook

When I first stepped into Compose, I spent hours debugging the same state reset after every rotation. It felt like a broken heartbeat. The good news: Compose offers built‑in tools that, when used correctly, keep your UI state alive across configuration changes. I’ll share the patterns that actually work and how to apply them in a real project.

Context

The Android framework guarantees that an Activity or Fragment is recreated whenever a configuration change occurs—screen rotation, locale change, or keyboard visibility. In the traditional View system, developers relied on

code
onSaveInstanceState
and restored values manually. Compose simplifies dentist‑like state management, yet many developers still lose data because they misuse
code
remember
or forget to tie state to a
code
ViewModel
. This post tackles the root cause, explains the proper lifecycle handling, and presents a clean, testable approach that fits into a modern MVVM architecture.

State Retention with rememberSaveable

code
remember
is Compose's primary state holder, but it only survives recompositions, not configuration changes. The companion function,
code
rememberSaveable
, leverages the Android
code
Bundle
infrastructure to persist the value across process death as well.

kotlin
@Composable
fun CounterScreen() {
    var count by rememberSaveable { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

[!TIP]
Wrap primitive or

code
Parcelable
types in
code
rememberSaveable
. For complex objects, use a
code
@Parcelize
data class or a custom
code
Saver
.

BeforeAfter Configuration Change
code
remember
→
code
0
code
remember
→
code
0
(reset)
code
rememberSaveable
→
code
0
code
rememberSaveable
→
code
5
(kept)

[!NOTE]

code
rememberSaveable
only works with types that the
code
Bundle
can serialize. If you need to preserve custom objects, implement a
code
Saver
.

When the state is a

code
StateFlow
or
code
LiveData
exposed from a
code
ViewModel
, Compose automatically observes it. The
code
ViewModel
survives configuration changes, so the UI state is preserved without manual bundle handling.

ViewModel as the Source of Truth

Using a

code
ViewModel
decouples UI logic from lifecycle. The
code
ViewModel
holds the real data and exposes it as immutable streams. Compose consumes these streams and recomposes when the data changes.

kotlin
class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() {
        _count.update { it + 1 }
    }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsState()

    Button(onClick = viewModel::increment) {
        Text("Count: $count")
    }
}

[!IMPORTANT]
The

code
viewModel()
helper automatically scopes the
code
ViewModel
to the nearest
code
NavBackStackEntry
or
code
Activity
, ensuring it survives configuration changes.

When the user rotates the device, Compose will recreate the composables, but the

code
ViewModel
remains intact. The
code
collectAsState()
callაზ will re‑attach to the same
code
StateFlow
, instantly restoring the UI.

Comparison: remember vs ViewModel

ScenarioBest PracticeWhy
Simple UI counters
code
rememberSaveable
Minimal boilerplate, no dependencies
Network data, business logic
code
ViewModel
+
code
StateFlow
Lifecycle‑aware, testable, single source of truth
Shared data between screensShared
code
ViewModel
Keeps state consistent across navigation

Handling Compose Lifecycle Events

Compose also offers

code
DisposableEffect
and
code
LaunchedEffect
for side‑effects that need to run once or on key changes. When the configuration changes, these effects are re‑created, so you must manage cleanup explicitly.

kotlin
@Composable
fun DataFetcher(viewModel: DataViewModel = viewModel()) {
    val data by viewModel.data.collectAsState()

    LaunchedEffect(Unit) {
        viewModel.loadData()
    }

    // UI here
}

[!WARNING]
Forgetting to cancel long‑running operations in

code
DisposableEffect
can lead to memory leaks or duplicated network calls after rotation.

If you need to preserve a coroutine that must outlive a composable, launch it in the

code
ViewModel
instead. Compose’s
code
rememberCoroutineScope
is tied to the composable’s lifecycle and will cancel on recomposition.

kotlin
AcknowledgementScreen(viewModel: AcknowledgementViewModel = viewModel()) {
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch {
            viewModel.saveAcknowledgement()
        }
    }) {
        Text("Accept")
    }
}

[!NOTE]
Move heavy logic into the

code
ViewModel
. Keep the composable lean, only responsible for UI.

Persisting UI State with SavedStateHandle

If you need to restore state after a process death, combine

code
ViewModel
with
code
SavedStateHandle
. It stores values in the same
code
Bundle
that Android uses for configuration changes but also persists them if the process is killed.

kotlin
class TodoViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _todos = MutableStateFlow(
        savedStateHandle.get<List<Todo>>("todos") ?: emptyList()
    )
    val todos: StateFlow<List<Todo>> = _todos.asStateFlow()

    fun addTodo(todo: Todo) {
        val updated = _todos.value + todo
        _todos.value = updated
        savedStateHandle.set("todos", updated)
    }
}

[!TIP]
Use

code
SavedStateHandle
for UI‑specific data that should survive process death but is not part of your domain model.

Putting It All Together: A Mini‑App Example

Below is a minimal Compose app that demonstrates state preservation across rotation, process death, and navigation qualify.

kotlin
@Composable
fun MainScreen() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = "counter") {
        composable("counter") { CounterScreen() }
        composable("details") { DetailScreen() }
    }
}

@Composable
fun CounterScreen() {
    val viewModel: CounterViewModel = viewModel()
    val count by viewModel.count.collectAsState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center
    ) {
        Text("Count: $count", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = viewModel::increment) {
            Text("Increment")
        }
    }
}

@ strike to comment out
kotlin
class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() = _count.update { it + 1 }
}
eri

Running this app, rotating the device leaves the counter intact. If you kill the process (force stop) and reopen the app, the counter resets—because we didn’t persist it. Add a

code
SavedStateHandle
:

kotlin
class CounterViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    private val _count = MutableStateFlow(
        savedStateHandle.get<Int>("count") ?: 0
    )
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() {
        val newValue = _count.value + 1
        _count.value = newValue
        savedStateHandle.set("count", newValue)
    }
}

Now the counter survives even a full process kill.

Key Takeaways

  • Use
    code
    rememberSaveable
    for simple, local UI state
    that can be serialised by a
    code
    Bundle
    .
  • Wrap complex, shared, or long‑lived data in a
    code
    ViewModel
    and expose it as
    code
    StateFlow
    or
    code
    LiveData
    .
  • Persist critical UI state with
    code
    SavedStateHandle
    when you need it to survive process death.
  • Always clean up side‑effects with
    code
    DisposableEffect
    or move them to the
    code
    ViewModel
    to avoid leaks.

These patterns keep your Compose UI stable, testable, and consistent across the unpredictable landscape of Android configuration changes.

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this