How to Restore Previous UI State with Kotlin StateFlow in Android
When developing multi-step screens or dialog flows in Jetpack Compose and Android, using a StateFlow emitting a sealed interface is an efficient way to model UI states. However, because a StateFlow holds only a single active state at any given time, transitioning to a new state—such as moving from an input screen (StateA) to a Loading indicator and then a result screen (StateB)—overwrites the previous state data.
If a user presses the back button on StateB, you need a reliable way to restore StateA along with all the user's previously entered inputs. Below, we examine why this happens and explore the clean architectural approaches to solve it.
The Core Issue: State Flow Loss
In a standard single-state ViewModel design, your state might look like this:
sealed interface UiState {
data object Initial : UiState
data class StateA(
val availableTimes: List<String>,
val hour: String = "",
val minute: String = "",
val isPm: Boolean = false
) : UiState
data object Loading : UiState
data class StateB(val result: String) : UiState
}When the user transitions from StateA to Loading, the instance of StateA (containing user inputs for hour, minute, and isPm) is replaced and garbage collected. When onBackClicked() is triggered, the ViewModel no longer has reference to those form inputs unless they were cached or persisted separately.
Solution 1: Cache the State in the ViewModel (Quickest Solution)
If your team strictly enforces a single UiState emission and all logic stays inside a single ViewModel (such as within a Compose DialogFragment flow), you can maintain a private reference to the last valid StateA instance inside the ViewModel.
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Initial)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// Private cache to store user input from StateA
private var cachedStateA: UiState.StateA? = null
init {
fetchInitialData()
}
private fun fetchInitialData() {
viewModelScope.launch {
delay(500)
val initialState = UiState.StateA(
availableTimes = listOf("10:00 AM", "11:00 AM", "12:00 PM")
)
cachedStateA = initialState
_uiState.value = initialState
}
}
fun onInputChanged(hour: String, minute: String, isPm: Boolean) {
val currentState = _uiState.value as? UiState.StateA ?: return
val updatedState = currentState.copy(
hour = hour,
minute = minute,
isPm = isPm
)
cachedStateA = updatedState // Keep cache up to date
_uiState.value = updatedState
}
fun onNextClicked() {
val screenAState = _uiState.value as? UiState.StateA ?: return
cachedStateA = screenAState
viewModelScope.launch {
_uiState.value = UiState.Loading
delay(1000)
_uiState.value = UiState.StateB(
result = "${screenAState.hour}:${screenAState.minute}"
)
}
}
fun onBackClicked() {
// Restore cached StateA if coming back from StateB or Loading
cachedStateA?.let { restoredState ->
_uiState.value = restoredState
}
}
}Solution 2: Separate Form State from Screen/Navigation State (Clean Architecture)
While caching the state works well, a cleaner architectural approach is to separate persistent screen data (Form State) from transient screen UI status (Navigation / Display State).
Instead of placing user inputs directly inside individual sealed states, hold a dedicated internal Data Class for form data inside the ViewModel:
data class FormData(
val availableTimes: List<String> = emptyList(),
val hour: String = "",
val minute: String = "",
val isPm: Boolean = false
)
class BetterViewModel : ViewModel() {
private var formData = FormData()
private val _uiState = MutableStateFlow<UiState>(UiState.Initial)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun onInputChanged(hour: String, minute: String, isPm: Boolean) {
formData = formData.copy(hour = hour, minute = minute, isPm = isPm)
_uiState.value = UiState.StateA(formData)
}
fun onBackClicked() {
// Easily recreate StateA at any time using saved formData
_uiState.value = UiState.StateA(formData)
}
}Summary
When working with single-state StateFlow architectures in Android Kotlin apps:
- State Cache: Save the current state instance into a private property in the ViewModel before transitioning to transient states like
LoadingorStateB. - Form State Separation: Maintain a backing form state object that outlives UI navigation changes for multi-step flows.
Both techniques allow you to keep your sealed interface pattern intact while delivering a seamless back-navigation user experience in Jetpack Compose dialogs and screens.