Lesson 19 of 83 intermediate

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

remember is like a whiteboard in a meeting room — it holds info while the meeting is ongoing, but get wiped when everyone leaves. rememberSaveable is like taking a photo of the whiteboard before leaving — the info survives even if the room is cleared. State hoisting is like the meeting chair collecting all decisions and redistributing them — one source of truth, everyone else just listens.

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

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. 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. 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. 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. 4. onSubmit is a lambda passed down from the screen — ScoreEntryCard does not know about ViewModels or coroutines, it just fires the callback
  5. 5. GradeViewModel holds '_uiState: MutableStateFlow' as the single source of truth for the entire screen
  6. 6. submitGrade() updates isSubmitting to true immediately for UI feedback, calls the use case, then updates state based on success or failure
  7. 7. GradeScreen is the composable that bridges ViewModel and UI — it collects uiState with collectAsStateWithLifecycle() (stops collecting when screen is backgrounded)
  8. 8. GradeScreenContent is fully stateless — receives uiState and onSubmit — it's what you write unit tests against
  9. 9. FilterStateSaver demonstrates listSaver — save converts FilterState to a List, restore reconstructs it from the List
  10. 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?
Look at state type, mutability, survival across rotation, and whether state hoisting boundaries are correct.
Show answer
Bug 1: 'var count = remember { 0 }' — remember wraps the MutableState but 'count' is typed as Int not MutableState<Int>. Changing count++ does NOT trigger recomposition because Compose is not observing a plain Int. Fix: var count by remember { mutableStateOf(0) } (delegation) or val count = remember { mutableStateOf(0) } then count.value++. Bug 2: Follows from Bug 1 — if count were correctly a MutableState, count++ would work with delegation. But with the broken code in Bug 1 it just mutates a local variable. Bug 3: The search query is in remember — if the user rotates the device while typing a long search query, it is lost. Fix: var query by rememberSaveable { mutableStateOf('') }. Bug 4: This is actually a correct pattern (not a bug) — the parent receives the value via the onAction callback, which IS the correct state hoisting / event-flowing-up approach. The comment implies a misunderstanding. The child correctly owns its internal state and surfaces it via a callback. No fix needed — this IS the UDF pattern.

Explain like I'm 5

Imagine you're drawing a picture. remember is like drawing on a whiteboard — great while you're working, but erased when class ends. rememberSaveable is like drawing in a notebook — even if class is cancelled and rescheduled, your drawing is still there. State hoisting means the teacher (parent) holds the master copy of the drawing and lets you (child) look at it and suggest changes — but only the teacher can actually change it. That way everyone always sees the same picture.

Fun fact

rememberSaveable internally hooks into the Android SavedStateRegistry — the same mechanism that onSaveInstanceState() uses. This means Compose state saved with rememberSaveable lands in the same bundle as traditional View-based saved state, making hybrid Compose+View apps work seamlessly.

Hands-on challenge

Build a multi-step form with 3 screens (PersonalInfo, ContactInfo, Review). Each step's entered data must survive rotation. The Review screen displays all collected data. Implement state hoisting so individual field composables are fully stateless. The ViewModel owns the final submission state. Use rememberSaveable with a custom Saver for a data class holding all form fields.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery