Skip to content

State Management

Screens expose StateFlow<UiState<ScreenData>> from their ViewModels and collect it with collectAsStateWithLifecycle() inside the destination's Screen composable. UiState keeps data, loading, and an error event together. The helpers handle common Result-returning operations, with important boundaries around exceptions, concurrent calls, and event delivery described below.

Concepts

API Purpose
UiState<T> Current data, a loading flag, and OneTimeEvent<Throwable?> for an error
StatefulComposable Renders data, a loading indicator, and an error callback
updateState Synchronously replaces data while preserving loading and error
updateStateWith Launches an operation returning Result<T> and replaces data on success
updateWith Launches an operation returning Result<Unit> without replacing data
OneTimeEvent<T> Allows at-most-once consumption per wrapper instance

The state helpers and wrapper live in core/ui/.../utils/StatefulComposable.kt; the event wrapper lives in core/android/.../utils/OneTimeEvent.kt.

The UiState<T> wrapper

data class UiState<T : Any>(
    val data: T,
    val loading: Boolean = false,
    val error: OneTimeEvent<Throwable?> = OneTimeEvent(null),
)

Data remains present during loading and after a returned failure. Start with values the screen can render: HomeScreenData uses an empty list, while ItemScreenData uses empty name/price strings and a generated ID for a new item. A non-null data container does not prohibit nullable fields. Use null when absence is meaningful, and make the UI handle that case.

The wrapper's properties are read-only, but an event's consumed flag is mutable. Copying a UiState without replacing error retains the same event and its consumption state. Separate flows or custom state types can be appropriate for independently observed concerns; this wrapper is a shared default.

StatefulComposable renders content even while loading and adds a centered loading indicator. It consumes an error and attempts to call onShowSnackbar(message, SnackbarAction.REPORT, error); the callback's Boolean return is ignored. It does not implement retry or guarantee that the snackbar is displayed. See event delivery before relying on it for critical acknowledgement.

Update functions

Function Use when Replaces data? Async?
updateState Form edits and toggles Yes No
updateStateWith An operation returns new screen data On returned success Yes
updateWith An action returns no new data No Yes

The async helpers return Unit, not the operation's Result or a Job. Map the result inside the operation when success must change screen data. They use the context ViewModel's scope; they do not choose an IO dispatcher. Repository/data-source code owns the relevant dispatcher boundary.

updateState

These are actual Item form methods:

fun updateName(name: String) {
    _itemUiState.updateState { copy(jetpackName = name) }
}

fun updatePrice(priceString: String) {
    _itemUiState.updateState { copy(jetpackPrice = priceString) }
}

The lambda returns replacement data. Loading and the existing error event remain unchanged. Because it uses MutableStateFlow.update, the transform can be evaluated again under contention: keep side effects out of it. Thrown exceptions propagate to the caller.

Keep the price as raw text so clearing it, entering 12e, or typing 1. preserves the exact input. "1.".toDoubleOrNull() accepts the trailing decimal point, but converting the result back to text would normalize it to "1.0". Preserving input and accepting a parsed number are different concerns. At save, Item trims the text and calls toDoubleOrNull() ?: 0.0. Empty or unparseable text becomes zero; this is fallback behavior, not price validation. No non-negative-price validation is present.

updateStateWith

Starting the coroutine sets loading and replaces the old error with OneTimeEvent(null). A returned success replaces data and clears loading. A returned failure stores its throwable in a new event, preserves the current data, and clears loading. The implementation also rejects a successful null payload, although T : Any excludes null in ordinary typed calls.

Current Item limitation: createOrUpdateJetpack() ignores the repository's Result and returns success with navigateBack = OneTimeEvent(true). A failed repository save therefore still requests navigation and exposes no error. Its load catch also constructs a state without storing it, leaving the default form without a load error. These behaviors are covered by characterization tests and tracked under Item failure handling.

The following is a proposed replacement for the save method inside ItemViewModel, not the current implementation. It uses existing imports and APIs and maps a returned repository failure through to the helper:

fun createOrUpdateJetpack() {
    _itemUiState.updateStateWith {
        val jetpack = Jetpack(
            id = jetpackId,
            name = jetpackName.trim(),
            price = jetpackPrice.trim().toDoubleOrNull() ?: 0.0,
        )
        homeRepository.createOrUpdateJetpack(jetpack).map {
            copy(navigateBack = OneTimeEvent(true))
        }
    }
}

This addresses returned save failures only. It does not fix load errors, thrown exceptions, event delivery, or edits made during suspension. Repository success concerns local persistence and the sync-request boundary, not confirmed remote synchronization; see Data.

updateWith

Home deletes an item through a Result-returning repository operation:

fun deleteJetpack(jetpack: Jetpack) {
    _homeUiState.updateWith {
        homeRepository.markJetpackAsDeleted(jetpack)
    }
}

This helper has the same loading/error setup and returned-failure handling as updateStateWith, but does not replace data on completion. Data changes instead arrive through the repository Flow.

Exceptions, cancellation, and concurrent calls

Neither async helper catches thrown exceptions or uses finally to reset loading. If the operation throws, including CancellationException, execution exits without recording a returned failure and can leave loading true. Return a Result for expected operation failures; keep cancellation propagating. The repository's suspendRunCatching boundary is described in Data. Calling getOrThrow() inside a helper is not equivalent to returning the repository Result.

A call that sees loading = true is dropped, rather than queued. The check occurs before launching, but loading is set inside the coroutine. Calls queued before that coroutine runs can both start. This is not a concurrency lock; overlapping completions can clear loading while another operation is still running. The Async state lifecycle follow-up tracks exception cleanup and overlap behavior.

The operation receives a snapshot of data read inside the coroutine. If updateStateWith suspends and later returns a copy of that snapshot, it overwrites synchronous edits made in the meantime. updateWith also receives a snapshot, but does not write it back on completion.

For independent operations, use separate operation state when their loading/error lifetimes need to differ. A single loading flag cannot represent independent progress. As an extension, choose whether to disable edits during save, merge selected result fields into current data, or serialize operations. Those policies require implementation and tests; the helpers do not supply them.

Resetting and optimistic updates

An illustrative Item data reset is _itemUiState.updateState { ItemScreenData() }. This resets the form, creates a new ID, and preserves loading/error. Replacing the whole wrapper with _itemUiState.value = UiState(ItemScreenData()) also resets loading/error, but does not cancel an in-flight job; its eventual completion can still write state. Decide how to end or coordinate that job before resetting an active form. Resetting an edited item to a new UUID changes its identity.

The template persists local writes before background synchronization; that does not confirm remote success. An optimistic UI extension that updates display state before local persistence needs explicit failure handling and rollback. Restoring an old snapshot can erase newer edits, so define how concurrent edits are preserved before adding rollback.

Context parameters

The async helpers declare context(viewModel: ViewModel) and launch into that ViewModel's viewModelScope. Inside a ViewModel member, Kotlin can supply the enclosing ViewModel context:

// Declaration excerpt; the body is omitted.
context(viewModel: ViewModel) inline fun <reified T : Any> MutableStateFlow<UiState<T>>.updateStateWith(
    crossinline operation: suspend T.() -> Result<T>,
)

The project uses Kotlin 2.4.10 without an explicit -Xcontext-parameters flag. Follow the existing build configuration; no additional flag or dependency is needed to call these helpers.

OneTimeEvent<T>

getContentIfNotHandled() atomically marks the wrapper consumed, then returns its content. Later calls return null. A null payload is indistinguishable from an already-consumed event through this method. peekContent() reads without consuming. Competing consumers share one flag; creating a new wrapper creates a new flag, even for equal content.

Consumption does not confirm delivery or completion of navigation/snackbar work, retry cancellation, or persist across process death. The current StatefulComposable consumes during composition and keys its effect on the callback. Existing Compose tests demonstrate two limitations:

  • Recomposition after consumption can remove the effect and cancel an unfinished callback.
  • A new error under the same callback key can be consumed without launching a new callback.

A discarded composition can also consume before committing; this is a source-reviewed risk rather than a reproduced test case. The Event delivery follow-up tracks changing consumption and effect lifetime together.

ItemScreen consumes its navigation event inside an effect instead:

LaunchedEffect(screenData.navigateBack, onBackClick) {
    if (screenData.navigateBack.getContentIfNotHandled() == true) {
        onBackClick()
    }
}

This avoids consuming during the composition body, but still does not acknowledge completed navigation. Do not infer that a plain Boolean necessarily repeats on every recomposition: effect keys and effect lifetime also determine execution. For the current Item save failure behavior, see updateStateWith.

Subscription lifetimes

stateInDelayed(initialValue, viewModelScope) shares the upstream with SharingStarted.WhileSubscribed(5000L). It starts when collected, stops the upstream five seconds after the last subscriber leaves, retains its latest value, and restarts upstream for a later subscriber. Reading .value alone does not start collection.

Home and Item place onStart { getJetpacks() } / onStart { getJetpack() } before this sharing operator. Those methods launch repository collection separately in viewModelScope. When the shared upstream stops, those separate collectors remain active. Restarting it launches another collector; they accumulate until the ViewModel is cleared. Settings uses the same separate-job pattern with a zero sharing timeout. Screen lifecycle collection alone does not fix that lifetime mismatch. The Observation lifetime follow-up includes Profile and preserving locale refresh behavior.

Testing

Place the following complete example alongside Item's existing unit tests in feature:home. The module already has the test dependencies. The fake is supplied explicitly; no Hilt test graph or database is needed. Turbine starts collection before editing, so the exposed shared state actually observes the change. This example checks input preservation, not repository persistence.

package dev.atick.feature.home.ui.item

import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import dev.atick.core.testing.rule.MainDispatcherRule
import dev.atick.data.model.home.Jetpack
import dev.atick.data.repository.home.HomeRepository
import dev.atick.data.utils.SyncProgress
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test

class ItemInputExampleTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun `keeps a trailing decimal point`() = runTest {
        val viewModel = ItemViewModel(InputRepository(), existingJetpackId = null)
        viewModel.itemUiState.test {
            assertThat(awaitItem().data.jetpackPrice).isEmpty()
            viewModel.updatePrice("1.")
            assertThat(awaitItem().data.jetpackPrice).isEqualTo("1.")
            cancelAndIgnoreRemainingEvents()
        }
    }
}

private class InputRepository : HomeRepository {
    override fun getJetpacks(): Flow<List<Jetpack>> = emptyFlow()
    override fun getJetpack(id: String): Flow<Jetpack> = emptyFlow()
    override suspend fun createOrUpdateJetpack(jetpack: Jetpack): Result<Unit> =
        Result.success(Unit)
    override suspend fun markJetpackAsDeleted(jetpack: Jetpack): Result<Unit> =
        Result.success(Unit)
    override suspend fun sync(): Flow<SyncProgress> = emptyFlow()
}

Run ./gradlew :feature:home:testDebugUnitTest. MainDispatcherRule uses an eager test dispatcher by default. That is useful here, but does not exercise calls queued before loading becomes true. Use a controlled dispatcher and suspended operations when testing overlap or edits during save.

The existing suites provide complementary coverage:

Suite Coverage
StatefulComposableTest Synchronous transforms, returned success/failure, loading, error reset
UiStateContractTest Queued overlap, thrown exceptions, cancellation, suspended edits
StatefulComposableEventTest Actual Compose effect cancellation and replacement-error behavior
OneTimeEventTest Consumption and peeking, including concurrent access
ItemViewModelTest Raw input/parsing, current failed save/load behavior, Home/Item collector accumulation

Tests documenting current defects are characterization tests, not evidence that those behaviors are desirable. Inspect error content with peekContent() when the assertion must not consume it. Clear ViewModel scopes in lifetime tests so their separately launched collectors are cancelled.

Further reading

  • Architecture — UI and data boundaries
  • Data — repository Result/Flow contracts and local-first persistence
  • Navigation — destination arguments and event integration
  • Guide — feature integration and a complete Home delete test
  • Core UI Module