Compose State: remember, rememberSaveable & State Hoisting
Master state ownership, survival across recomposition, and the unidirectional data flow pattern interviewers love
Open interactive version (quiz + challenge)Real-world analogy
What is it?
State in Jetpack Compose is the single source of truth that drives the UI. remember holds state across recompositions within the composable's lifetime. rememberSaveable additionally survives configuration changes and process death via bundle serialization. State hoisting extracts state ownership upward, enabling unidirectional data flow where state flows down as parameters and events flow up as lambda callbacks — making composables stateless, testable, and reusable.
Real-world relevance
In a school management app, a GradeEntryScreen holds a text field for each student's score. The text field value is remembered locally with rememberSaveable (survives rotation mid-entry). Once the teacher taps Submit, the data flows up via an onSubmit lambda to the ViewModel which owns the canonical list of grades in a StateFlow. The composable is stateless — it receives the list and callbacks. Rotating the device or backgrounding the app does not lose the in-progress entry.
Key points
- What remember does — remember { } stores an object in the composition tree across recompositions. When the composable is removed from the tree (e.g. navigated away), the remembered value is discarded. It is purely in-memory, scoped to the composable's lifetime in the tree.
- rememberSaveable for process death — rememberSaveable { } survives configuration changes AND process death by serializing to the SavedStateHandle-backed Bundle. Uses Saver interface internally. Ideal for transient UI state like scroll position, text input, toggle state that should not reset on rotation.
- Custom Saver with rememberSaveable — For non-primitive types, implement Saver or use listSaver / mapSaver helpers. Example: rememberSaveable(stateSaver = ColorSaver) { mutableStateOf(Color.Red) }. Interviewers ask this when dealing with complex UI state that must survive rotation.
- State hoisting definition — State hoisting moves state up from a composable to its caller, replacing the internal state with a value: T + onValueChange: (T) -> Unit pair. The composable becomes stateless and therefore reusable, testable, and previewable.
- Unidirectional Data Flow (UDF) — State flows DOWN the tree (parent to child), events flow UP (child calls lambdas). No child should ever mutate shared state directly. This mirrors MVI architecture and makes UI deterministic — the same state always produces the same UI.
- Stateful vs Stateless composables — Stateful composables own their state (convenient but hard to test/preview). Stateless composables receive state and event callbacks (testable, reusable). Best practice: have a thin stateful wrapper that connects to the ViewModel, and pass hoisted state into a stateless inner composable.
- ViewModel as state owner — For screen-level state that survives navigation and configuration changes, own state in ViewModel using StateFlow or mutableStateOf inside ViewModel. The composable collects it via collectAsStateWithLifecycle() — never owns it. This is the correct pattern for production apps.
- When to use remember vs ViewModel — Use remember for ephemeral, local UI state (is dropdown expanded?, current animation target). Use ViewModel for business-relevant state (user data, list items, loading/error). If losing the state on rotation would be annoying to the user, put it in ViewModel or rememberSaveable.
- mutableStateOf vs MutableStateFlow — mutableStateOf is Compose-specific — it integrates directly with the snapshot system and triggers recomposition automatically. MutableStateFlow is coroutines-based — it works in ViewModel without Compose dependency. Convert to Compose state via .collectAsStateWithLifecycle() at the composable boundary.
- State delegation with by — var text by remember { mutableStateOf('') } uses Kotlin property delegation. It unpacks to getValue/setValue operators so you read text directly instead of text.value. Makes code cleaner — but type is String not MutableState so you cannot pass it as a MutableState reference.
- Derived state anti-pattern — Avoid computing expensive values inline in the composable body — they run on every recomposition. Use derivedStateOf { } to memoize computations that depend on other state, so the derived value only recomputes when its inputs change, not on every recomposition cycle.
- Interview signal: state hoisting boundary — Senior interviews ask: 'how far up should you hoist state?' Answer: hoist to the lowest common ancestor that needs it. Hoisting too high causes unnecessary recomposition of unrelated composables. Hoisting too low prevents sharing. The ViewModel boundary is the natural ceiling for screen-level state.
Code example
// --- State hoisting: stateless TextField ---
@Composable
fun ScoreTextField(
score: String, // state flows DOWN
onScoreChange: (String) -> Unit, // events flow UP
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = score,
onValueChange = onScoreChange,
label = { Text("Score") },
modifier = modifier
)
}
// --- Stateful wrapper using rememberSaveable ---
@Composable
fun ScoreEntryCard(studentId: String, onSubmit: (String, String) -> Unit) {
// Survives rotation — teacher doesn't lose their in-progress entry
var score by rememberSaveable { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
ScoreTextField(score = score, onScoreChange = { score = it })
Button(onClick = { onSubmit(studentId, score) }) {
Text("Submit")
}
}
}
// --- ViewModel owns screen-level state ---
class GradeViewModel(private val submitGradeUseCase: SubmitGradeUseCase) : ViewModel() {
private val _uiState = MutableStateFlow(GradeUiState())
val uiState: StateFlow<GradeUiState> = _uiState.asStateFlow()
fun submitGrade(studentId: String, score: String) {
viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true) }
submitGradeUseCase(studentId, score)
.onSuccess { _uiState.update { it.copy(isSubmitting = false, submitted = true) } }
.onFailure { e -> _uiState.update { it.copy(isSubmitting = false, error = e.message) } }
}
}
}
// --- Screen composable: collects from ViewModel ---
@Composable
fun GradeScreen(
viewModel: GradeViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
GradeScreenContent(
uiState = uiState,
onSubmit = viewModel::submitGrade
)
}
// --- Custom Saver example ---
data class FilterState(val query: String, val sortAsc: Boolean)
val FilterStateSaver: Saver<FilterState, *> = listSaver(
save = { listOf(it.query, it.sortAsc) },
restore = { FilterState(it[0] as String, it[1] as Boolean) }
)
@Composable
fun FilterBar() {
var filter by rememberSaveable(stateSaver = FilterStateSaver) {
mutableStateOf(FilterState("", true))
}
// filter survives rotation and process death
}Line-by-line walkthrough
- 1. ScoreTextField receives 'score: String' (the current value) and 'onScoreChange: (String) -> Unit' (the event callback) — it owns NO state, purely renders what it's given
- 2. ScoreEntryCard is the stateful wrapper — it owns 'score' via rememberSaveable so rotating the device mid-entry does not wipe the teacher's in-progress work
- 3. When ScoreTextField's value changes, it calls onScoreChange which updates 'score' in ScoreEntryCard — event flows up, new state flows back down on recomposition
- 4. onSubmit is a lambda passed down from the screen — ScoreEntryCard does not know about ViewModels or coroutines, it just fires the callback
- 5. GradeViewModel holds '_uiState: MutableStateFlow' as the single source of truth for the entire screen
- 6. submitGrade() updates isSubmitting to true immediately for UI feedback, calls the use case, then updates state based on success or failure
- 7. GradeScreen is the composable that bridges ViewModel and UI — it collects uiState with collectAsStateWithLifecycle() (stops collecting when screen is backgrounded)
- 8. GradeScreenContent is fully stateless — receives uiState and onSubmit — it's what you write unit tests against
- 9. FilterStateSaver demonstrates listSaver — save converts FilterState to a List, restore reconstructs it from the List
- 10. rememberSaveable(stateSaver = FilterStateSaver) hooks the custom saver into the bundle serialization mechanism
Spot the bug
@Composable
fun CounterScreen() {
var count = remember { 0 } // Bug 1
Column {
Text("Count: ${count}")
Button(onClick = { count++ }) { // Bug 2
Text("Increment")
}
}
}
@Composable
fun SearchBar(viewModel: SearchViewModel = hiltViewModel()) {
var query by remember { mutableStateOf("") } // Bug 3
TextField(
value = query,
onValueChange = {
query = it
viewModel.search(it)
}
)
}
@Composable
fun ParentScreen() {
ChildWidget(
onAction = { value ->
// Bug 4: parent tries to read child's internal state
}
)
}
@Composable
fun ChildWidget(onAction: (String) -> Unit) {
var internalState by remember { mutableStateOf("") }
Button(onClick = { onAction(internalState) }) {
Text("Do Action")
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- State and Jetpack Compose — Official Guide (Android Developers)
- rememberSaveable and Saver (Android Developers)
- State Hoisting in Jetpack Compose (Android Developers)
- Advanced State in Compose — Google I/O (YouTube / Google)
- ViewModel and State in Compose (Android Developers)