Hoisting Jetpack Compose State with rememberSaveable for Testability and Configuration‑Change Safety
Learn how to lift UI state into a ViewModel and wrap it with rememberSaveable so composables stay pure, survive rotation, and are easier to unit test.
29 Aug 2025, 05:05 UTC

Useful answer
Hoist the mutable state of a composable into a ViewModel and expose it as an immutable State object. Wrap the ViewModel’s state with rememberSaveable (via a SavedStateHandle) so the value survives configuration changes and process death. The UI becomes a pure function of its inputs, which simplifies unit testing and reduces unnecessary recompositions.
Worked example
We’ll build a simple counter that increments when a button is pressed.
ViewModel
@HiltViewModel
class CounterViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// Use SavedStateHandle to make the state survive process death
private val _count = savedStateHandle.getState("count") {
mutableStateOf(0)
}
// Expose as read‑only State for the UI
val count: State = _count
fun onCountChange(delta: Int) {
_count.value += delta
}
}
Where to run: In an Android Studio project with Hilt and Compose enabled. No special permissions are required.
Composable
@Composable
fun CounterScreen(viewModel: CounterViewModel = hiltViewModel()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Count: ${viewModel.count.value}", style = MaterialTheme.typography.h5)
Button(onClick = { viewModel.onCountChange(1) }) {
Text("Increment")
}
}
}
Explanation:
- The composable receives no mutable state; it only reads
viewModel.count.valueand callsviewModel.onCountChange. hiltViewModel()provides a ViewModel scoped to the Activity or Navigation graph.- The ViewModel stores the counter in a
MutableStatethat is saved inside aSavedStateHandle. When the system recreates the Activity after rotation or low‑memory kill, the Handle restores the value, andrememberSaveable(used internally bygetState) recomposes the UI with the restored count.
Verification steps
- Run the app on an emulator or physical device.
- Tap the button a few times to increase the counter.
- Rotate the device (or press Ctrl+F11 on the emulator).
- Confirm the counter retains the last value instead of resetting to zero.
- Open Logcat and watch for recompositions: only the
Buttonrecomposes when clicked; theTextrecomposes when the ViewModel’s state changes.
Limits and common mistakes
When hoisting adds unnecessary boilerplate
For UI that only needs a transient value (e.g., a temporary text field that does not need to survive rotation), hoisting to a ViewModel adds extra classes and indirection. In such cases, keep the state locally with remember { mutableStateOf(...) }.
Hoisting non‑observable types
If you expose a plain Int or String from the ViewModel instead of a State or StateFlow, the composable will not recompose when the value changes, leading to a stale UI. Always expose observable wrappers.
Failing to wrap mutable state in an observable type
Writing private var count = 0 and updating it directly prevents Compose from detecting changes. Use mutableStateOf, StateFlow, or LiveData.
Using rememberSaveable with non‑parcelable objects
Attempting to save a custom data class without making it Parcelable or providing a custom Saver throws an IllegalArgumentException at runtime. Either implement Parcelable or supply a Saver that converts the object to a parcelable representation (e.g., JSON string).
Practical way to check the result
After implementing the solution, use Android Studio’s Layout Inspector:
- Deploy the app and open the Inspector.
- Select the
CounterScreencomposable. - Verify that the node has no
mutableStateOffields in its properties; only incoming parameters (countandonCountChange) are shown. - Change the count via the button and observe that the composable does not recreate its local state—only the parameters update.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.