Skip to content
Commitwise
Menu
Android Development

ViewModel and unidirectional data flow

Keep screen state in a ViewModel, expose it as one immutable UiState, send user actions back as events, and survive rotation and process death on Android.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A bug report: “After I rotate the phone, the Add button is enabled but the form is empty, and the loading spinner is still there.” Somewhere, the screen’s state is split across the composable, the ViewModel and a couple of flags, and after a configuration change they no longer agree.

Unidirectional data flowUnidirectional data flowAn architecture rule where state flows down from one owner to the UI, and user actions flow back up as events.Open in glossary → (UDF) is the fix Android’s architecture guidance is built on: one owner holds the state, the UI only displays it, and every change starts as an event sent back to that owner. State flows down, events flow up, in one direction.

New to some of these? Open what you need, or skip ahead.

Basics:What are Activities and configuration changes?

On Android, an Activity is an app screen (or, in Compose apps, often the single container for all screens). Android creates and destroys Activities as the user moves around.

A configuration change, like rotating the phone or switching to dark mode, destroys the Activity and creates a new one. Anything stored only in the Activity (or in plain remember in Compose) is lost. Android also kills apps in the background to save memory (process death), and restores them when the user returns. Where you keep state decides what survives each of these.

Basics:What is a composable function?

In Jetpack Compose, UI is written as functions marked @Composable:

@Composable
fun Greeting(name: String) {
    Text("Hello, $name!")
}

A composable describes what the screen should show for the data it receives. It doesn’t return a view; it emits UI elements. When the data changes, Compose calls the function again (recomposition) and updates only what changed. Composables are combined by calling each other inside layouts like Column and Row.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:Mutable or immutable?

A mutable object can be changed after it’s created (list.add(x), user.name = "Ana"). An immutable one can’t: to “change” it, you create a new one with the new value.

Immutable data is easier to reason about: nobody can change it behind your back, and it’s safe to share between parts of the program (and between threads). Kotlin’s val properties and List, Java’s record and List.of, and Swift’s let and structs are the usual tools.

Basics:What does asynchronous mean?

Synchronous code does one thing at a time and waits for each step to finish. Asynchronous code starts something slow (a download), lets other work continue in the meantime, and continues when the result is ready.

Swift (async/await), Kotlin (suspend functions in coroutines) and Java (virtual threads, futures) each have their own way to write asynchronous code that still reads top to bottom, like normal code. The waiting doesn’t freeze the app.

Part Not started

State down, events up

Think of a scoreboard at a basketball game:

  • The scorekeeper (the ViewModel) is the only one who changes the score.
  • The scoreboard (the screen) just shows whatever the scorekeeper says. It never changes a number itself.
  • When something happens on the court, the referee tells the scorekeeper (“two points for the home team”). That’s an event.

Because only one person changes the score, the board can never show two different scores, and if the board is replaced (rotation!), the new one shows the same score immediately.

A ViewModelMVVMModel-View-ViewModel: the UI observes a ViewModel that holds screen state and logic, separate from the UI code.Open in glossary → is Android’s scorekeeper: it survives rotation, holds the screen’s state, and receives the user’s actions.

UDF makes the screen a function of a single immutable UiState owned by a state holder (usually a ViewModel), with user input sent back as events (onAction(...) or individual functions). The ViewModel survives configuration changes, applies business logic, and exposes StateFlow<UiState>; the UI collects it lifecycle-aware and renders. It’s MVVM with the MVI discipline of a single state object.

The senior concerns are consistency and survival: atomic state updates under concurrency, one-off events (navigation, messages) without losing or replaying them, restoring after process death with SavedStateHandle without blowing the binder limit, text fields and asynchronous state, and testing the ViewModel as a pure state machine. All in the Detailed version.

data class TodoUiState(
    val todos: List<Todo> = emptyList(),
    val newTitle: String = "",
    val isSaving: Boolean = false,
) {
    val canAdd: Boolean get() = newTitle.isNotBlank() && !isSaving   // derived, not stored
}

sealed interface TodoAction {
    data class TitleChanged(val title: String) : TodoAction
    data object AddClicked : TodoAction
    data class Toggled(val id: Long) : TodoAction
}

class TodoViewModel(private val repository: TodoRepository) : ViewModel() {
    private val _state = MutableStateFlow(TodoUiState())
    val state: StateFlow<TodoUiState> = _state.asStateFlow()        // read-only for the UI

    init {
        viewModelScope.launch {
            repository.observeTodos().collect { todos -> _state.update { it.copy(todos = todos) } }
        }
    }

    fun onAction(action: TodoAction) {
        when (action) {
            is TodoAction.TitleChanged -> _state.update { it.copy(newTitle = action.title) }
            TodoAction.AddClicked -> addTodo()
            is TodoAction.Toggled -> viewModelScope.launch { repository.toggle(action.id) }
        }
    }

    private fun addTodo() {
        val title = _state.value.newTitle.trim()
        if (title.isEmpty()) return
        viewModelScope.launch {
            _state.update { it.copy(isSaving = true) }
            repository.add(title)
            _state.update { it.copy(newTitle = "", isSaving = false) }
        }
    }
}

@Composable
fun TodoScreen(viewModel: TodoViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    TodoContent(state = state, onAction = viewModel::onAction)
}

@Composable
fun TodoContent(state: TodoUiState, onAction: (TodoAction) -> Unit) {
    Column(Modifier.padding(16.dp)) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            TextField(
                value = state.newTitle,
                onValueChange = { onAction(TodoAction.TitleChanged(it)) },
                modifier = Modifier.weight(1f),
            )
            Button(onClick = { onAction(TodoAction.AddClicked) }, enabled = state.canAdd) { Text("Add") }
        }
        LazyColumn {
            items(state.todos, key = { it.id }) { todo ->
                Row(Modifier.fillMaxWidth().clickable { onAction(TodoAction.Toggled(todo.id)) }) {
                    Checkbox(checked = todo.done, onCheckedChange = null)
                    Text(todo.title)
                }
            }
        }
    }
}

Follow one tap through it:

  1. The user taps Add. TodoContent doesn’t change anything; it calls onAction(AddClicked): an event goes up.
  2. The ViewModel saves the todo and updates _state with copy(...): a new state object.
  3. The screen collects state, gets the new TodoUiState, and recomposes: state goes down.

Two details make it reliable: the UI only sees a read-only StateFlow, and the state is one immutable object, so a screen can never show half of an update. TodoContent has no ViewModel at all, which means you can preview it with any state.

Who’s allowed to change the state?

Spot the bugA cart that empties itself

Tap the line with the bug.

class CartViewModel(private val repository: CartRepository) : ViewModel() {
    val state = MutableStateFlow(CartUiState())

    fun checkout() {
        viewModelScope.launch {
            repository.placeOrder(state.value.items)
        }
    }
}

// In the cart screen:
Button(onClick = { viewModel.state.value = viewModel.state.value.copy(items = emptyList()) }) {
    Text("Clear cart")
}

Reducers and state granularity

As screens grow, onAction plus _state.update becomes an implicit reducer. Making it explicit (fun reduce(state: UiState, result: Result): UiState, pure and synchronous) separates deciding the next state from side effects (network, database, analytics), which run in the ViewModel and feed results back as new inputs. That’s MVI without a framework, and it makes the state logic unit-testable without coroutines.

Granularity matters for both correctness and performance: one UiState per screen keeps updates atomic, but composables should read only what they need (pass state.todos, not state) so Compose can skip unaffected parts. Derived values (canAdd, totals, filtered lists) are computed properties or combined flows, never separately stored fields that can drift.

Read, then write

Predict the output

Two coroutines on Dispatchers.Default each increment the state 10,000 times. What gets printed?

fun main() {
    val state = MutableStateFlow(0)
    runBlocking {
        repeat(2) {
            launch(Dispatchers.Default) {
                repeat(10_000) { state.value = state.value + 1 }
            }
        }
    }
    println(state.value)
}
  • One immutable UiState per screen in a StateFlow; expose it read-only, update atomically with _state.update { it.copy(...) } (never read-then-write from concurrent coroutines).
  • An explicit reduce(state, result) separates deciding the next state from side effects and makes state logic testable without coroutines. Derived values are computed, never stored.

Check this part

  1. In unidirectional data flow, how does the UI change the state?

  2. Why does the ViewModel expose StateFlow instead of MutableStateFlow?

  3. Why use _state.update { } instead of _state.value = _state.value.copy(...)?

  4. A composable takes the whole UiState but uses only the list of todos. What does that cost?

One owner, the ViewModel, holds the screen's state as a single immutable UiState exposed as a read-only StateFlow. The UI only displays it and sends events up; every change is a new state made with copy().

Part Not started

How long a ViewModel lives

A ViewModel is created the first time a screen asks for it and kept until the screen is gone for good:

Predict the output

The user opens the screen, rotates the phone twice, then presses Back. What does the log show?

class CounterViewModel : ViewModel() {
    init { Log.d("VM", "created") }

    override fun onCleared() {
        Log.d("VM", "cleared")
    }
}

class CounterActivity : ComponentActivity() {
    private val viewModel: CounterViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewModel                                      // first access creates it
        setContent { /* … */ }
    }
}

The ViewModel does not survive the system killing the app in the background (process death). That’s what SavedStateHandle is for, covered in the senior section.

Things that happen once

Some results aren’t state: “navigate to the receipt”, “show a snackbar”. If you put navigateToReceipt = true in the state and forget to reset it, rotation shows the state again and navigates twice. The recommended approach is to keep it as state, and let the UI tell the ViewModel when it’s handled:

data class EditProfileUiState(
    val name: String = "",
    val isSaving: Boolean = false,
    val savedProfileId: Long? = null,        // set once the save succeeded
)

class EditProfileViewModel(private val repository: ProfileRepository) : ViewModel() {
    private val _state = MutableStateFlow(EditProfileUiState())
    val state = _state.asStateFlow()

    fun save() = viewModelScope.launch {
        _state.update { it.copy(isSaving = true) }
        val id = repository.save(_state.value.name)
        _state.update { it.copy(isSaving = false, savedProfileId = id) }
    }

    fun onNavigated() = _state.update { it.copy(savedProfileId = null) }   // handled: clear it
}

@Composable
fun EditProfileScreen(viewModel: EditProfileViewModel, onSaved: (Long) -> Unit) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    LaunchedEffect(state.savedProfileId) {
        state.savedProfileId?.let { id ->
            onSaved(id)                          // navigate
            viewModel.onNavigated()              // and tell the ViewModel it's done
        }
    }
    // … form UI …
}

Practice: a login form

Let’s build a login screen’s ViewModel with UDF, step by step:

  1. List what the screen shows: email, password, whether the button is enabled, a spinner while signing in, an error message, and “signed in”.
  2. Put it in one data class, deriving what you can (canSubmit) instead of storing it.
  3. List what the user does: types an email, types a password, taps Sign in. Those become functions or actions.
  4. Write each action as a state update: _state.update { it.copy(...) }, plus a coroutine for the network call.

Before the exercise, one mistake that’s tempting when a ViewModel “needs” to show a toast or start an activity:

Spot the bugA ViewModel that holds on to the screen

Tap the line with the bug.

class LoginViewModel(private val activity: Activity) : ViewModel() {

    fun onLoginSucceeded(user: User) {
        Toast.makeText(activity, "Welcome, " + user.name, Toast.LENGTH_SHORT).show()
        activity.startActivity(Intent(activity, HomeActivity::class.java))
    }
}

Exerciseeasy

Login screen state

Write LoginViewModel with UDF:

  • LoginUiState holds the email, the password, whether sign-in is in progress, an optional error message, and whether the user is signed in,
  • Sign in is enabled only when the email contains @ and the password has at least 8 characters, and not while signing in,
  • signIn() calls auth.signIn(email, password) (a suspend function returning Boolean and throwing IOException when offline) and updates the state,
  • typing clears the previous error.
›

canSubmit is derived from the other fields: make it a computed property of the state class, not a stored field that could disagree.

›

signIn() sets isLoading = true, calls the repository inside viewModelScope.launch, then sets isLoading = false and either isSignedIn = true or an error. Guard against double taps with if (!state.value.canSubmit) return.

SolutionTry it yourself first
data class LoginUiState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val error: String? = null,
    val isSignedIn: Boolean = false,
) {
    val canSubmit: Boolean
        get() = "@" in email && password.length >= 8 && !isLoading
}

class LoginViewModel(private val auth: AuthRepository) : ViewModel() {
    private val _state = MutableStateFlow(LoginUiState())
    val state: StateFlow<LoginUiState> = _state.asStateFlow()

    fun onEmailChange(email: String) = _state.update { it.copy(email = email, error = null) }

    fun onPasswordChange(password: String) = _state.update { it.copy(password = password, error = null) }

    fun signIn() {
        val current = _state.value
        if (!current.canSubmit) return                       // ignores double taps too
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, error = null) }
            val newState = try {
                if (auth.signIn(current.email.trim(), current.password)) {
                    _state.value.copy(isLoading = false, isSignedIn = true)
                } else {
                    _state.value.copy(isLoading = false, error = "Wrong email or password.")
                }
            } catch (e: IOException) {
                _state.value.copy(isLoading = false, error = "You're offline. Try again.")
            }
            _state.value = newState
        }
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel, onSignedIn: () -> Unit) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    LaunchedEffect(state.isSignedIn) { if (state.isSignedIn) onSignedIn() }

    Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        OutlinedTextField(state.email, viewModel::onEmailChange, label = { Text("Email") })
        OutlinedTextField(
            state.password, viewModel::onPasswordChange,
            label = { Text("Password") },
            visualTransformation = PasswordVisualTransformation(),
        )
        state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
        Button(onClick = viewModel::signIn, enabled = state.canSubmit) {
            if (state.isLoading) CircularProgressIndicator(Modifier.size(18.dp)) else Text("Sign in")
        }
    }
}

Everything the screen can show is in LoginUiState, and every change goes through a ViewModel function, so testing is straightforward: call onEmailChange, onPasswordChange, signIn() with a fake AuthRepository, and check state.value. Rotation mid-sign-in keeps the spinner and the typed email, because both live in the ViewModel.

Surviving process death

A ViewModel survives rotation but not the process being killed in the background, which Android does routinely. SavedStateHandle persists small values into the saved-instance-state Bundle:

  • store user input and identifiers: the query, the selected tab, the draft text, the id of the item being edited, the checkout step,
  • reload data from the repository (the database survives; the network can refetch),
  • keep it small: the whole Bundle for an Activity is limited by the binder transaction buffer (about 1 MB shared across the process), and exceeding it crashes with TransactionTooLargeException (puzzle below),
  • test it with “Don’t keep activities” and adb shell am kill while the app is in the background.

Text fields and asynchronous state

TextField(value = state.text, onValueChange = …) expects the new value synchronously. If the update goes through a coroutine, a debounce or a flatMap before reaching state, fast typing makes the cursor jump and characters disappear. Update text state immediately (_state.update on the main thread, as above), keep it in the UI with rememberTextFieldState() (Compose’s TextFieldState), and run validation or search from the state, not between keystroke and state.

Testing ViewModels

Replace Dispatchers.Main with a StandardTestDispatcher through a JUnit rule, inject fake repositories, drive the ViewModel through its public functions, and assert state.value (or the sequence of states with Turbine). With Hilt, @HiltViewModel + @Inject constructor(savedStateHandle: SavedStateHandle, repo: …) gives you the same constructor in tests, where you pass SavedStateHandle(mapOf("id" to 42L)) directly.

Saving too much

Spot the bugA crash when the app goes to the background

Tap the line with the bug.

class SearchViewModel(
    private val savedState: SavedStateHandle,
    private val repository: SearchRepository,
) : ViewModel() {
    val results: StateFlow<List<Product>> = savedState.getStateFlow("results", emptyList())

    fun search(query: String) = viewModelScope.launch {
        savedState["results"] = repository.search(query)     // up to 2,000 Parcelable products
    }
}

Exercisemedium

A checkout screen's state layer

A checkout screen has three steps: address, payment method, review and pay. Design the ViewModel:

  • one UiState for the screen, with the current step and the data entered so far,
  • user actions as events, including going back a step,
  • Pay must never charge twice (double taps, retries after a timeout, rotation during payment),
  • after process death, the user returns to the same step with their inputs, without re-entering card details,
  • after success, navigate to the receipt exactly once.
›

Separate what’s typed (persist in SavedStateHandle), what’s loaded (the cart, fetched again), and what’s in progress (the payment). The payment needs an idempotency key generated once per checkout.

›

Store the idempotency key in SavedStateHandle too: if the process dies mid-payment, the retry after restoration must use the same key, so the server recognizes it.

SolutionTry it yourself first
enum class CheckoutStep { ADDRESS, PAYMENT, REVIEW }

data class CheckoutUiState(
    val step: CheckoutStep = CheckoutStep.ADDRESS,
    val address: Address? = null,
    val paymentMethodId: String? = null,        // a token from the payment SDK, never card numbers
    val cart: Cart? = null,
    val isPaying: Boolean = false,
    val error: String? = null,
    val receiptId: String? = null,
) {
    val canPay: Boolean get() = step == CheckoutStep.REVIEW && address != null &&
        paymentMethodId != null && cart != null && !isPaying && receiptId == null
}

sealed interface CheckoutAction {
    data class AddressEntered(val address: Address) : CheckoutAction
    data class PaymentChosen(val paymentMethodId: String) : CheckoutAction
    data object Back : CheckoutAction
    data object Pay : CheckoutAction
    data object ReceiptShown : CheckoutAction
}

class CheckoutViewModel(
    private val saved: SavedStateHandle,
    private val carts: CartRepository,
    private val payments: PaymentRepository,
) : ViewModel() {
    // Small, essential inputs survive process death; the cart is reloaded.
    private val idempotencyKey: String =
        saved["idempotencyKey"] ?: UUID.randomUUID().toString().also { saved["idempotencyKey"] = it }

    private val _state = MutableStateFlow(
        CheckoutUiState(
            step = saved["step"] ?: CheckoutStep.ADDRESS,
            address = saved["address"],
            paymentMethodId = saved["paymentMethodId"],
            receiptId = saved["receiptId"],
        )
    )
    val state: StateFlow<CheckoutUiState> = _state.asStateFlow()

    init {
        viewModelScope.launch { carts.observeCart().collect { cart -> _state.update { it.copy(cart = cart) } } }
        // Keep SavedStateHandle in sync with the inputs that matter.
        viewModelScope.launch {
            state.collect { s ->
                saved["step"] = s.step
                saved["address"] = s.address                 // Address is @Parcelize
                saved["paymentMethodId"] = s.paymentMethodId
                saved["receiptId"] = s.receiptId
            }
        }
    }

    fun onAction(action: CheckoutAction) {
        when (action) {
            is CheckoutAction.AddressEntered ->
                _state.update { it.copy(address = action.address, step = CheckoutStep.PAYMENT) }
            is CheckoutAction.PaymentChosen ->
                _state.update { it.copy(paymentMethodId = action.paymentMethodId, step = CheckoutStep.REVIEW) }
            CheckoutAction.Back -> _state.update { s ->
                if (s.isPaying) s else s.copy(step = previous(s.step), error = null)   // no Back while paying
            }
            CheckoutAction.Pay -> pay()
            CheckoutAction.ReceiptShown -> Unit                  // receiptId stays: the flow is finished
        }
    }

    private fun pay() {
        val current = _state.value
        if (!current.canPay) return                           // double taps and repeated calls stop here
        _state.update { it.copy(isPaying = true, error = null) }
        viewModelScope.launch {
            val result = runCatching {
                payments.pay(current.cart!!, current.address!!, current.paymentMethodId!!, idempotencyKey)
            }
            result.exceptionOrNull()?.let { if (it is CancellationException) throw it }
            _state.update { s ->
                result.fold(
                    onSuccess = { receipt -> s.copy(isPaying = false, receiptId = receipt.id) },
                    onFailure = { s.copy(isPaying = false, error = "Payment didn't go through. You haven't been charged twice; try again.") },
                )
            }
        }
    }

    private fun previous(step: CheckoutStep) = when (step) {
        CheckoutStep.ADDRESS, CheckoutStep.PAYMENT -> CheckoutStep.ADDRESS
        CheckoutStep.REVIEW -> CheckoutStep.PAYMENT
    }
}

@Composable
fun CheckoutScreen(viewModel: CheckoutViewModel, onPaid: (receiptId: String) -> Unit) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    LaunchedEffect(state.receiptId) {
        state.receiptId?.let { onPaid(it) }                   // navigation keyed on the receipt
    }
    // … render the current step from state, sending CheckoutAction values …
}

The decisions behind it:

  • No double charge, by design rather than by luck. canPay blocks double taps; the idempotency key is created once per checkout and persisted, so a retry after a timeout, a rotation or even process death sends the same key, and the server charges at most once. The isPaying flag is UI; the key is the real guarantee.
  • Process death: only small inputs and ids are saved (step, address, a payment token, never card data, which must never be in a Bundle); the cart comes back from its repository. Note that a restored isPaying would be a lie (the coroutine is gone), so it isn’t saved: on return, the user sees the review step and can pay again with the same key.
  • Navigation once: the receipt id is state; LaunchedEffect(receiptId) navigates when it appears, and because the checkout is finished, the screen leaves the back stack (pop up to the cart) so it can’t trigger again.
  • Testable as a state machine: with a fake PaymentRepository, tests assert “Pay twice → one call”, “failure → error and same key on retry”, and “restore from SavedStateHandle(mapOf(...)) → same step and inputs”.

Deep dives

Deep diveHow a ViewModel survives rotationInterview favorite

An Activity owns a ViewModelStore, a map of ViewModels by key. On a configuration change, the old Activity hands its store to the new one through onRetainNonConfigurationInstance, an object the system keeps across the recreation, so viewModels() in the new Activity finds the same instances.

When the Activity is finished for good (not changing configurations), ViewModelStore.clear() clears each ViewModel: viewModelScope is cancelled and onCleared() runs. Process death is different: the store lives in memory, so it’s gone, and only SavedStateHandle (written to the saved-instance-state bundle) comes back.

ConcernPractice
Expose stateprivate val _state = MutableStateFlow(UiState()), val state = _state.asStateFlow()
Update state_state.update { it.copy(...) } (atomic), derived values as computed properties
Events from UIonAction(Action) or one function per action; UI never writes state
One-off resultsmodel as state (savedId), consume in LaunchedEffect, then clear or finish the flow
RotationViewModel survives; onCleared() only when finished for good
Process deathSavedStateHandle for inputs and ids only (binder limit ~1 MB); reload data
Text inputsynchronous updates or TextFieldState; never debounce before the field’s state
CollectcollectAsStateWithLifecycle(), pass only needed fields to child composables
TestMainDispatcherRule, fake repositories, assert state.value / Turbine

Gotchas: public MutableStateFlow, Activity/Context in a ViewModel, value = value.copy races off the main thread, events stored as state and replayed, big lists in SavedStateHandle, card data in saved state.

class ScreenViewModel(private val repo: Repo, private val saved: SavedStateHandle) : ViewModel() {
    private val _state = MutableStateFlow(ScreenState(query = saved["query"] ?: ""))
    val state: StateFlow<ScreenState> = _state.asStateFlow()

    fun onAction(action: ScreenAction) = when (action) {
        is ScreenAction.QueryChanged -> {
            saved["query"] = action.query
            _state.update { it.copy(query = action.query) }
        }
        ScreenAction.Refresh -> refresh()
    }

    private fun refresh() {
        viewModelScope.launch {
            val items = repo.load(_state.value.query)
            _state.update { it.copy(items = items) }
        }
    }
}

Check this part

  1. What happens to a ViewModel when the phone is rotated?

  2. Why must a ViewModel never hold a reference to an Activity?

  3. What does SavedStateHandle protect against that a ViewModel alone doesn't?

  4. A ViewModel saves a 2,000-item list in SavedStateHandle. What can happen?

A ViewModel survives rotation but not process death, so small user input and ids go in SavedStateHandle and data is reloaded. One-off results like navigation are state the UI acknowledges, and a ViewModel never holds an Activity.

Key takeaways

  • One owner (the ViewModel) holds the screen’s state; the UI displays it and sends events back.
  • Expose one immutable UiState as a read-only StateFlow, update it with copy, and derive what you can.
  • A ViewModel survives rotation and is cleared only when the screen finishes; it must never hold an Activity or View.
  • Model “navigate after save” as state the UI reacts to once, not as a flag that’s replayed.
  • Make the reducer explicit and pure; run side effects in the ViewModel and feed their results back as inputs.
  • Use update {} for atomic changes, and keep text field state synchronous.
  • Survive process death with SavedStateHandle holding inputs and ids only; stay far below the binder limit.
  • Design guarantees (idempotency keys, navigation keyed on results) instead of relying on UI flags.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.