Mobile Core Concepts
State Management
Why "who owns the state and what survives rotation" is the question every screen has to answer, with SSOT and UDF as the named principles, the two kinds of state holder, one-shot events as state, and the survival ladder - ViewModel, SavedStateHandle, rememberSaveable - that tells you which surface keeps each piece of state alive.
State management is the question every mobile system-design loop asks twice - once architecturally ("who owns this state?") and once mechanically ("what happens when the screen rotates / the process dies / the user uninstalls?"). Three lenses every state-bearing class has to pass: ownership (who is allowed to mutate it?), flow (which direction do mutations propagate?), and survival (what gives the state away - rotation, kill, uninstall?). This page covers all three: SSOT and UDF as the named architectural principles, the two-kind state-holder split (ViewModel vs plain class) with the compoundability rule, one-shot events handled as state, and the survival ladder from in-memory references down to Room and the server. The MVVM / MVI shape above lives in Architecture; lifecycle and process-death triggers live in Platform Knowledge.
SSOT and the UDF cycle
Two named principles every interview panel listens for - and they compose so tightly they belong in one section.
Single Source of Truth (SSOT). Every piece of state has exactly one owner. The owner is the only thing that can mutate it. Everyone else reads an immutable view. The discussion-grade payoff: bugs become traceable ("when did this become wrong?" has one place to look, not five), and impossible interleavings ("UI A wrote at the same time as ViewModel B") become structurally unrepresentable.
The panel-grade phrasing: "the ViewModel owns this state and exposes it immutably" beats "I'd put it in a MutableStateFlow". The first sentence is design; the second is implementation.
Unidirectional Data Flow (UDF). State flows down; events flow up. The cycle, in four steps:
- Source of truth produces state. The ViewModel exposes a
StateFlow<UiState>viastateIn(viewModelScope, WhileSubscribed(5_000), initial); the UI subscribes viacollectAsStateWithLifecycle(). The two named idioms a candidate should reference - both prevent specific failures (lifecycle-unaware collection wastes work; eager sharing keeps cold flows running when no one is watching). - UI renders the state. Pure function: same state in, same UI out. The UI never mutates state in place.
- User event reaches the source of truth. A button press calls a ViewModel method, or dispatches a sealed
Intent. - Source of truth mutates internal state and re-emits. The new state flows back to the UI in step 1.
Why it scales: where state changes, where it is transformed, and where it is consumed are three different places, each testable in isolation. A bug in transformation can't have come from the UI; a bug in rendering can't have come from the source. With bidirectional flow (UI mutates state, state callbacks mutate UI), every component is a suspect on every bug.
State holders - two kinds
A state holder is the class that holds state on behalf of the UI. There are two kinds with different shapes and different lifetimes, and a panel will test that the candidate uses the right one for each piece of state.
Business-logic state holder. A ViewModel. Survives configuration changes. Lives as long as its navigation destination. Talks to the data layer. Exposes screen-level UiState via a StateFlow.
UI-logic state holder. A plain class, often named *ScreenState or *AppState. Lives only as long as the composable that creates it. Talks to lifecycle-scoped sources (NavController, WindowSizeClass, LazyListState). Composable-driven UI logic - "show the rail or the bottom bar?", "is the drawer open?" - lives here.
// Business logic - ViewModel, talks to the data layer, survives rotation.
@HiltViewModel class FeedViewModel @Inject constructor(
private val getFeed: GetFeedUseCase,
) : ViewModel() {
val uiState: StateFlow<FeedUiState> = ...
fun onLike(postId: String) { ... }
}
// UI logic - plain class, lifecycle-scoped, doesn't survive rotation.
@Stable class FeedScreenState(
val navController: NavController,
val windowSizeClass: WindowSizeClass,
val listState: LazyListState,
) {
val shouldShowRail: Boolean
get() = windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact
}The split matters because the two have incompatible dependencies. A ViewModel cannot reference NavController (it would leak the Activity across rotations); a plain class cannot survive a configuration change (its LazyListState is composition-scoped). Trying to put either responsibility in the other type fails in production.
Compoundability. A state holder may depend on another only if the dependency has equal-or-shorter lifetime. A plain UI-logic class can depend on a ViewModel's exposed state - fine, the ViewModel outlives the plain class. The reverse - a ViewModel depending on a plain UI-logic state holder - is a leak. Passing a ViewModel instance into a plain class is the anti-pattern: it inverts the lifetime relationship, couples the UI to a concrete VM type, and lets the UI mutate state outside the SSOT contract. Pass what the plain class needs (a StateFlow to observe, a lambda to dispatch) instead of the holder itself.
Decision framework. Use a ViewModel when state must survive config change, when the source of truth is in the data layer, or when the screen has a navigation destination. Use a plain class when state is UI-scoped, lifecycle-bound, or composable-driven - window size, drawer open / closed, scroll position - anything that has no reason to live across an Activity recreation.
The survival surfaces
State on Android lives on one of five surfaces, each strictly weaker than the last:
- In-memory references inside Activity / Fragment / Composition. Die on config change.
- ViewModel fields (Hilt's
ActivityRetainedComponentscope). Survive rotation. Die on back-stack finish or process kill. - SavedStateHandle /
onSaveInstanceState/rememberSaveable. Survive background process death. Die when the user swipes the task away or the system ages it out. - DataStore / Room (app sandbox). Survive OS kill, reboot, update. Die on uninstall or "Clear data."
- Server / cloud. Survives uninstall and re-install.
The skill is matching state to the weakest surface that still preserves the user's mental model. Cached feed data shouldn't go to SavedStateHandle (huge and re-fetchable); a half-typed comment shouldn't be memory-only (a kill loses user work); auth tokens shouldn't go to plain SharedPreferences (they need EncryptedSharedPreferences / Keystore). Too weak surfaces as "I rotated and lost my place." Too strong surfaces as "I uninstalled and the app still has my dirty filter from a year ago."
ViewModel
A ViewModel is scoped to a ViewModelStoreOwner - usually an Activity, Fragment, or Compose Navigation entry. The store sits outside the owner instance: when the Activity is destroyed for a config change, the framework hands the same ViewModelStore to the new Activity, and the same ViewModel object resurfaces. When the Activity is destroyed for real (back-press, finish(), process death), clear() runs and onCleared() fires on every ViewModel inside.
@HiltViewModel
class FeedViewModel @Inject constructor(
private val getFeed: GetFeedUseCase,
) : ViewModel() {
val uiState: StateFlow<FeedUiState> =
getFeed().map(::toUi)
.stateIn(viewModelScope, WhileSubscribed(5_000), FeedUiState.Loading)
override fun onCleared() {
// Coroutines launched in viewModelScope are already cancelled by the framework.
}
}What this buys: rotation tears down the Activity and Composition, but getFeed()'s upstream Flow keeps its stateIn cache alive through the WhileSubscribed(5_000) grace window. The new Activity attaches a fresh collector, the StateFlow already has the latest value, and the UI redraws without flicker or re-fetch. If uiState were a remember { ... } inside a Composable, rotation would discard it.
ViewModel does not survive process death. The system can kill the process in the background; on return, the Activity is re-created, the ViewModelStoreOwner is brand new, and the ViewModel is constructed from scratch. The most-missed point in interviews - candidates conflate "survives rotation" with "survives everything short of uninstall."
Sources: ViewModel overview · Saving UI states
SavedStateHandle
SavedStateHandle is a key-value store backed by the same system Bundle that onSaveInstanceState writes to. The framework serializes it to disk before a background kill and rehydrates it before the ViewModel is reconstructed. Inject it into a Hilt ViewModel constructor and every value you put in it crosses process death automatically.
@HiltViewModel
class ComposeMessageViewModel @Inject constructor(
private val handle: SavedStateHandle,
private val send: SendMessageUseCase,
) : ViewModel() {
val draft: StateFlow<String> = handle.getStateFlow(KEY_DRAFT, "")
fun onDraftChanged(text: String) { handle[KEY_DRAFT] = text }
fun onSend() = viewModelScope.launch {
send(draft.value); handle[KEY_DRAFT] = ""
}
companion object { private const val KEY_DRAFT = "draft" }
}handle.getStateFlow(key, default) returns a hot StateFlow backed by the bundle; writes via handle[key] = ... push to subscribers immediately. The value is re-read from the bundle on every process recreation - the ViewModel is reconstructed with a non-empty SavedStateHandle, so the draft text is already populated when the first composition runs.
The hard cap: the Bundle travels inside a Binder transaction during state save, and Binder's effective payload limit is roughly 1 MB total for the whole Activity's saved state (the source of TransactionTooLargeException). SavedStateHandle shares that budget with every other saved-state surface. It holds coordinates, not data: tab index, search query, selected item id, scroll offset - not a list of feed posts or a decoded bitmap.
Navigation 2.x and Compose Navigation inject SavedStateHandle automatically through the Hilt @HiltViewModel + hiltViewModel() factory chain. Route arguments (/post/{postId}) appear under their declared keys before the ViewModel's init block runs.
Sources: SavedStateHandle · TransactionTooLargeException
What survives what
The reference table. Pick the weakest column that still gives the survival you need.
| State surface | Config change (rotation) | Process death (background kill) | OS kill / reboot | Uninstall / clear data |
|---|---|---|---|---|
remember { ... } in Composable | No | No | No | No |
| Activity / Fragment field | No | No | No | No |
ViewModel field | Yes | No | No | No |
rememberSaveable { ... } | Yes | Yes | No | No |
SavedStateHandle value | Yes | Yes | No | No |
onSaveInstanceState Bundle | Yes | Yes | No | No |
| DataStore (Preferences / Proto) | Yes | Yes | Yes | No |
| Room / SQLite | Yes | Yes | Yes | No |
| Encrypted Keystore-backed store | Yes | Yes | Yes | No |
| Server / cloud account | Yes | Yes | Yes | Yes |
onSaveInstanceState and SavedStateHandle survive process death only when the kill is in the background - swipe from Recents discards saved state and next launch starts from the home destination. DataStore and Room have identical survival semantics; the split is by shape - DataStore for small key-value config (settings, flags, last-used tab), Room for queryable structured data (feed cache, message history). A 50 MB blob in DataStore is wrong because DataStore rewrites the whole file on every key update.
rememberSaveable in Compose
rememberSaveable is the Compose analog of SavedStateHandle - same Bundle, same survival. It writes through to the saved-state registry of the closest ViewModelStoreOwner (or the Activity for plain Compose).
@Composable
fun SearchScreen() {
var query by rememberSaveable { mutableStateOf("") }
var isFiltersOpen by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState() // scroll position auto-saved via built-in Saver
// ...
}Saveable by default: primitives, String, Parcelable, Serializable, and types covered by a built-in Saver (the library ships ones for MutableState, LazyListState, ScrollState, TextFieldValue). For custom types, supply a Saver<T, S>:
data class FilterSet(val tags: Set<String>, val sort: Sort)
val FilterSetSaver = Saver<FilterSet, List<Any>>(
save = { listOf(it.tags.toList(), it.sort.name) },
restore = { FilterSet(
tags = (it[0] as List<String>).toSet(),
sort = Sort.valueOf(it[1] as String),
) },
)
@Composable
fun FilterChips() {
var filters by rememberSaveable(stateSaver = FilterSetSaver) {
mutableStateOf(FilterSet(emptySet(), Sort.Recent))
}
// ...
}rememberSaveable composes with ViewModel state when the split is right: ViewModel holds domain state (post list, loading phase); the composable holds view-local UI state (chip drawer open / closed, list scroll position). A LazyListState doesn't belong in a ViewModel - it references composition-scoped objects and breaks on teardown. The 1 MB budget applies: rememberSaveable writes into the same Bundle as SavedStateHandle. rememberSaveable { mutableStateOf(decodedBitmap) } is TransactionTooLargeException waiting to happen.
Sources: State and Jetpack Compose · State saving
Process death restoration patterns
SavedStateHandle and rememberSaveable are coordinates that point into durable storage, not the storage itself. After process death, the ViewModel is reconstructed with the coordinates in hand, and the data layer re-derives the rest. The "id + re-fetch" idiom is the canonical shape:
@HiltViewModel
class PostDetailViewModel @Inject constructor(
private val handle: SavedStateHandle, // contains "postId" from navigation
private val getPostDetail: GetPostDetailUseCase,
) : ViewModel() {
private val postId: String = checkNotNull(handle["postId"])
val state: StateFlow<PostDetailUiState> = getPostDetail(postId)
.map(::toUi)
.stateIn(viewModelScope, WhileSubscribed(5_000), PostDetailUiState.Loading)
}postId is a 24-byte string. The navigation framework wrote it to SavedStateHandle when the screen was opened; on restore, the ViewModel reads the id, the Use Case reads from Room (already on disk) and the network (if stale), and the user sees their post within milliseconds.
The wrong shape: storing the whole PostDetail - body, comments, author - in SavedStateHandle. Works for one post and fails on a list of 200. Always "id in SavedStateHandle, body in Room."
For form drafts, two patterns are accepted: SavedStateHandle for drafts under a few KB (comment box, search query, tab index), and Room or DataStore for unbounded drafts (document body, multi-image composer) - periodic flush during typing, restore on screen open. The Photo Gallery problem does the second: upload chunks and progress live in Room, the worker is a CoroutineWorker, and after kill WorkManager rehydrates while Room provides the resume offset; SavedStateHandle holds only which session the screen is viewing.
State hoisting in practice
UDF in the small: state lives at the lowest common ancestor of the components that read or write it, passed down as values, with events flowing up as callbacks. The four cases that cover ~all real-world composables:
- A leaf needing no shared state - inline
remember { mutableStateOf(...) }. - A leaf sharing state with a sibling - hoist to the closest common parent.
- Screen-local state that must survive rotation (filter drawer open / closed) -
rememberSaveableat the screen root. - State that drives domain logic or is the source of truth for what's on screen - hoist all the way to the
ViewModel.
@Composable
fun FeedScreen(vm: FeedViewModel = hiltViewModel()) {
val state by vm.uiState.collectAsStateWithLifecycle()
var isFiltersOpen by rememberSaveable { mutableStateOf(false) }
FeedContent(
state = state,
isFiltersOpen = isFiltersOpen,
onLike = vm::onLike, // event up
onToggleFilters = { isFiltersOpen = !isFiltersOpen },
)
}FeedContent is stateless - receives state, emits intent via callbacks, owns no ViewModel reference, mutates nothing. That makes it preview-able, testable with a fake state, and reusable. Passing the whole ViewModel into a child is the anti-pattern that breaks compoundability (see "State holders" above): couples the child to a concrete VM type, defeats preview, and inverts the lifetime relationship.
Single stream vs multiple streams
When the ViewModel has multiple pieces of state to expose, the panel will pull at one decision: one combined StateFlow<UiState> or several specialized streams?
Single stream is the default. One StateFlow<UiState> with one immutable data class. The UI subscribes once, gets every field in lockstep, never sees an inconsistent intermediate state. The cost: every field change diffs against the whole UiState, which only matters when fields update at very different rates.
Multiple streams earns its keep when (a) the fields are genuinely unrelated - Flow<List<Notification>> and Flow<UnreadCount> for two independent widgets that never coordinate; (b) one field updates 60 times a second and the others don't - animation values, scroll position; or (c) one field is a non-state type that can't sit inside an immutable data class - PagingData, which is mutable by design (see Compose).
When in doubt, single stream. The bug "two streams emitted, the UI saw a half-updated state" is real and shows up in production; the cost of one extra data class field rarely is.
One-shot events as state
Some UI updates are not state - they're discrete events that should fire exactly once: navigate to detail, show snackbar, dismiss the screen, request focus on a field. The naive shape - a nav: Destination? field in UiState - replays the event on every rotation and on every process-death restore. The user is sent back to detail every time the screen recomposes.
Three patterns, with what the panel hears for each:
SharedFlow(replay = 0)- emit events to a hot channel the UI collects. Works for cases where losing an event on a lifecycle gap is acceptable. The reason this isn't recommended as the default any more:SharedFlowwill drop emissions with no active collector, and the UI is not collecting while the screen isSTOPPED. Snackbars vanish on rotation.Channel<Effect>- older shape. Same lifecycle hazards asSharedFlow, plus consumed-once semantics. Mostly legacy in 2026 codebases.- Events as state with a consumed marker - the modern recommendation. Embed the event in
UiStateas a nullable value or a list of pending events; the UI consumes one and calls back to the ViewModel ("onUserMessageShown(id)"); the ViewModel clears it. Survives rotation (the event is in state), survives lifecycle gaps (the state still says "show this snackbar"), and is structurally impossible to deliver twice.
data class FeedUiState(
val posts: List<Post>,
val userMessage: UserMessage? = null, // event as a state field
)
class FeedViewModel : ViewModel() {
private val _state = MutableStateFlow(FeedUiState(posts = emptyList()))
val state: StateFlow<FeedUiState> = _state.asStateFlow()
fun onLikeFailed() = _state.update { it.copy(userMessage = UserMessage.LikeFailed) }
fun onUserMessageShown() = _state.update { it.copy(userMessage = null) } // UI clears
}The interview-grade point: a snackbar "lost on rotation" or "replayed on process death" is the symptom of treating events as flow emissions instead of as state. Embedding the event in state makes the lifecycle behavior obvious from the type.
Sources: UI events · State holders and UI state
See also
- Architecture - the named principles upstream of this page (SoC, drive UI from data, SSOT, UDF) plus MVVM / MVI / Repository as the architectural patterns that consume the state holders described here.
- Platform knowledge - process death, Activity lifecycle, and the conditions that trigger background kill are covered at the platform level there.
- Compose -
rememberSaveable, recomposition, stability annotations, and theLifecycleStartEffect/LifecycleResumeEffect/repeatOnLifecyclefamily that pairs withcollectAsStateWithLifecycle. - Persistence - Room and DataStore as the durable surfaces that
SavedStateHandle's "id" points into.
Further reading on developer.android.com:
- UI layer overview
- State holders and UI state
- State production pipeline
- UI events
- Architecture recommendations
Used in
- Photo Gallery App - upload sessions persist in Room and are resumed by WorkManager after kill; SavedStateHandle on the upload-detail screen carries only the session id and the current tab.
- Messenger App - the message-composer draft is held in SavedStateHandle (small, user-authored), conversation messages are in Room, and scroll position uses
rememberLazyListState()with its built-in Saver. - Doc Editor App - document body is checkpointed to Room periodically; SavedStateHandle holds the active document id and cursor position, so a kill mid-edit restores within a few hundred milliseconds.
- Newsfeed App - feed cache lives in Room, the visible cursor / pagination key sits in SavedStateHandle, and the filter chip selection uses
rememberSaveableat the screen root. - Music Player App - the active queue is persisted to Room, the now-playing track id is in SavedStateHandle, and the playback service holds the live MediaSession that survives a UI kill while music continues.
Done reading? Mark it so it sticks in your dashboard.