Stop Losing UI State on Rotation: State Hoisting with rememberSaveable in Jetpack Compose
Rotation wipes Compose state held in plain remember. Hoist it to the caller and switch to rememberSaveable, and your forms, toggles, and counters survive configuration changes with no ViewModel boilerplate.
20 Oct 2025, 10:14 UTC

The rotation bug you didn't know you shipped
Rotate your phone while filling out a form in a Compose app and watch the text vanish. This happens because a configuration change — rotation, dark mode toggle, locale switch — destroys and recreates the Activity, and with it every piece of state held in a plain remember. The fix is a two-part decision: hoist the state out of the leaf composable, and store it with rememberSaveable so Compose writes it into the saved-instance-state Bundle automatically. No ViewModel boilerplate required for simple UI state.
Why state inside a composable dies
Consider a minimal counter:
@Composable
fun BrokenCounter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}remember keeps the value across recompositions — the re-execution of composable functions when state changes — but not across the Activity being torn down. On rotation, composition starts fresh and count is zero again.
Hoist first, then make it saveable
State hoisting means moving the state up to the caller and passing value-plus-callback down. This makes the leaf composable stateless and reusable, and it puts the state where you control its lifetime:
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
Button(onClick = onIncrement) {
Text("Clicked $count times")
}
}
@Composable
fun CounterScreen() {
var count by rememberSaveable { mutableStateOf(0) }
Counter(count = count, onIncrement = { count++ })
}rememberSaveable behaves like remember, but it also serializes the value into the Bundle that Android hands the Activity during configuration changes. On recreation, the value is restored before the first composition. Rotation, dark mode flips, and font-scale changes all become non-events for this state.
Worked example: a settings screen
Drop this into MainActivity.setContent { ... } in any Compose project (tested behavior applies to Compose 1.x with compose-bom 2023.x or later):
@Composable
fun SettingsScreen() {
var notificationsEnabled by rememberSaveable { mutableStateOf(false) }
var displayName by rememberSaveable { mutableStateOf("") }
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Notifications", Modifier.weight(1f))
Switch(
checked = notificationsEnabled,
onCheckedChange = { notificationsEnabled = it }
)
}
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display name") }
)
}
}To verify: run the app, toggle the switch, type a name, then rotate the emulator (Ctrl+F11 or the rotate button in the emulator toolbar). Both values should be intact. If either resets, check that you used rememberSaveable and not remember, and that the state lives in a composable that survives the recreation (not inside a navigation destination that gets popped).
The trade-off: Bundles are small and picky
rememberSaveable only works for types the Bundle can hold: primitives, String, Parcelable, Serializable, and a few others. A complex object needs a custom Saver:
data class Filters(val minPrice: Int, val category: String)
val FiltersSaver = Saver(
save = { "${it.minPrice}|${it.category}" },
restore = { raw ->
val (price, cat) = raw.split("|")
Filters(price.toInt(), cat)
}
)
var filters by rememberSaveable(stateSaver = FiltersSaver) {
mutableStateOf(Filters(0, "all"))
}Two real limitations to keep in mind. First, the Bundle has a practical size budget (the system enforces a transaction limit around 1 MB shared across the whole app), so don't shove large lists or bitmaps into rememberSaveable — restoration will get slow or crash with a TransactionTooLargeException. Second, this state survives configuration changes and, on most devices, system-initiated process death — but it is not durable storage. If the user would be angry to lose the data after force-stopping the app, persist it in DataStore or Room and treat rememberSaveable as a cache of the in-progress UI.
A simple decision rule
Hoist state to the lowest common ancestor that needs it. Use rememberSaveable for small, UI-only state — selected tabs, text field contents, toggles, scroll positions. Promote to a ViewModel when the state is shared across screens, drives business logic, or needs to survive more than the Bundle can handle. The next time a tester reports "my form cleared when I rotated," you'll know exactly which two lines to change.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.