Embracing Compose Snapshot State for UI Layer State Production

Geting the best out of Kotlin, coroutines and compose in the UI layer

TJ Dahunsi

May 15 2026 · 16 mins

It's been a while since I wrote the Android state production and state holder docs. In It, I outlined the guiding principles for building the state for the UI layer by thinking of it as a series of changes over time caused by events.

Events cause state to change. Events happen, state just is.

Events and state

These docs were generally well received, however there was a mild bit of contention around what the vehicle for notifying the UI of changes should be:

In the doc, I showed examples for either, making sure to use verbiage like "can" instead of "should". In the time since, Kotlin, coroutines and compose have evolved to the point where I feel comfortable drawing a line in the sand. If you are using coroutines to produce state in an app for a Compose UI layer, your reactive / observable API should be Compose's snapshot state.

Why StateFlow

The assertion above is quite a statement, so let's dissect it. Reactive programming in the Android state production pipeline was where unidirectional data flow in Android development first took off. There were two roles:

  • The ViewModel / Presenter / Controller would create and update the state.

  • The UI would observe it, and update itself when it changed.

RxJava offered an API that made it relatively easy to meet the unidirectional data flow spec, although with an interesting side effect: it foisted immutable data as the primitive of state change all through the state production pipeline for the UI. At the time, this was a minor inconvenience, as it solved the following engineering challenges:

  • Separation of concerns: UI state has to be immutable to prevent the UI from trying to manage / modify its own state.

  • Inversion of control. When the UI state changes, the state producer will "push" a new immutable state to the UI causing it to rerender.

  • Single responsibility: As the UI simply needs to rerender on state updates, an observable type is needed that can represent both a stream, and a current value (snapshot) simultaneously. In RxJava this was the BehaviorSubject, and with coroutines, this is the StateFlow. Immutable data is best for this.

We've become inured to this over the years, but if you look closely, this is a local maximum in both user and developer experience, even with StateFlow, and especially in Jetpack Compose apps. Immutable data means:

  • Each state change is an object allocation, no matter how small.

  • Each state change means a top down diff of the UI tree. In compose the screen gets a new object, and then the nuances of recomposition come into play:

    • Instance equality checks for strong skipping down the tree.

    • Object equality checks for immutable classes.

  • Multiple observable types on the same screen:

    • A StateFlow is exposed to the UI

    • The UI has to collect the StateFlow as Compose State to be notified of changes.

In the last case, this causes the implicit reallocation of memoized onClick and other UI interaction lambdas per recomposition when they capture values in the state. One reallocation for the UI state class, can cascade down the UI tree and reallocate as many lambdas that capture it.

In small apps, these are largely negligible. In large and complex apps however, all these can add up significantly for what is a very avoidable problem. Surely we can do better than a StateFlow that tries to be both a representation of events over time, and a snapshot at a point in time for Compose consumers?

Why snapshot State

It's worth noting how recomposition works. In Compose UI, a MonotonicFrameClock CoroutineContext.Element is present in the CoroutineScope provided by Compose APIs like rememberCoroutineScope. Each time Compose State is read in a snapshot aware context (inside a @Composable method and in scopes like MeasureScope, GraphicsLayerScope, DrawScope, e.t.c) the read is tracked by the global snapshot system.

Each time the frame clock ticks (ticks are synchronized to the framerate of the device's display), any reads that have been tracked and invalidated will get notified. For UI elements this causes a "recomposition" where any UI code that reads the snapshot is re-executed to keep it up to date. For screen level state in a StateFlow, when collectAsState is invoked and tracked, any write to the UI state invalidates the code block reading the state: the entire screen. Ideally, we should only recompose in targeted parts of the UI tree and not from the top, which is what we get with a StateFlow.

That said, let's explore alternatives to the implicit semantics discussed above:

  • Single responsibility: Use focused types. Instead of a hybrid type that is both a reactive stream and a snapshot of the current value, we can use a single type that focuses on doing one thing and doing it really well. Compose snapshot State is a representation of a value at a point in time, and nothing else. While you could create a stream from it using a snapshotFlow, it isn't one by itself.

  • Inversion of control: "Pull", don't "push". As the UI runs at a given frame rate, it is a bit more reasonable for the UI to "pull" from the state holder when it's good and ready to, rather than have the producer "push" to it. This nuance is especially important if you've ever tried to coordinate shared element transitions or animations that need to be "first frame" ready. Consider the following:

    • You use an image loading library to fetch async images.

    • If you try to push data after the compose frame clock tick, you've missed the deadline and have to wait till the next frame.

  • Separation of concerns: Use interfaces. You could represent your UI state as an interface, define a mutable version for your state producer, and the UI only works with the interface type unaware of the backing version being mutable.

The ergonomics of producing State with snapshot State

None of the above is new. At the time of the publication of the state production and state holder docs, state production pipelines could certainly be created with Compose state as the observable primitive, however there was one major downside that prevented my full throated endorsement: developer ergonomics.

Take the following data class representation for a screen's state from the state production doc:

data class AddEditTaskUiState( val title: String = "", val description: String = "", val isTaskCompleted: Boolean = false, val isLoading: Boolean = false, val userMessage: String? = null, val isTaskSaved: Boolean = false )

Then the equivalent for using compose snapshot state:

@Stable interface AddEditTaskUiState { val title: String val description: String val isTaskCompleted: Boolean val isLoading: Boolean val userMessage: String? val isTaskSaved: Boolean } private class MutableAddEditTaskUiState : AddEditTaskUiState() { override var title: String by mutableStateOf("") override var description: String by mutableStateOf("") override var isTaskCompleted: Boolean by mutableStateOf(false) override var isLoading: Boolean by mutableStateOf(false) override var userMessage: String? by mutableStateOf<String?>(null) override var isTaskSaved: Boolean by mutableStateOf(false) }

In the above, the Compose state equivalent:

  • Is verbose. Twice the amount of written code to represent the same thing.

  • Is not Kotlin ecosystem friendly:

    • The Kotlin Serialization plugin requires properties to have backing fields (usually var or val in the primary constructor) to automatically generate serializers.

    • Is not Kotlin Parcelize plugin compatible for the same reason.

  • Is not UI test friendly. If you wanted to set up a UI test for your screen, you'd end up defining another representation of the AddEditTaskUiState that would most likely be a data class. So, triple the code that the immutable data class did once.

Leveraging the Kotlin Compiler

One of the many great things about Kotlin is its compiler. We already know the kind of code we'd like to write; it's simple code. The compiler already lets us:

  • Write synchronous looking asynchronous code with Coroutines.

  • Write declarative UI code that self updates with Jetpack Compose.

  • Build compile time verified dependency injection graphs with Metro.

The snapshot mutable version of AddEditTaskUiState is a rather simple transform. The Kotlin Compiler can easily handle this with a plugin, and I've written one. With the Snapshottable compiler plugin, you can define:

@Stable @Snapshottable interface AddEditTaskUiState { @SnapshotSpec data class Immutable( val title: String = "", val description: String = "", val isTaskCompleted: Boolean = false, val isLoading: Boolean = false, val userMessage: String? = null, val isTaskSaved: Boolean = false ): AddEditTaskUiState }

And have the Compiler generate the mutable snapshot state variant. This allows for full control of the Immutable version; it could be @Parcelable and / or @Serializable, allowing you to use the Compose state variant at runtime to write simple idiomatic kotlin code.

The generated code for the above is equivalent to the following:

@Stable @Snapshottable interface AddEditTaskUiState { val title: String val description: String val isTaskCompleted: Boolean val isLoading: Boolean val userMessage: String? val isTaskSaved: Boolean @SnapshotSpec data class Immutable( override val title: String = "", override val description: String = "", override val isTaskCompleted: Boolean = false, override val isLoading: Boolean = false, override val userMessage: String? = null, override val isTaskSaved: Boolean = false ): AddEditTaskUiState { fun toSnapshotMutable(): AddEditTaskUiState.SnapshotMutable } // Generated @Stable class SnapshotMutable: AddEditTaskUiState { override var title: String override var description: String override var isTaskCompleted: Boolean override var isLoading: Boolean override var userMessage: String? override var isTaskSaved: Boolean fun toSnapshotSpec(): AddEditTaskUiState.Immutable } }

Producing state with Compose Snapshot state

Now that developer ergonomics for defining UI state with compose snapshot state are improved, the next thing is actually producing state with it. Again, there is an ergonomic inconvenience here that Compose State has, that StateFlow doesn't: the stateIn operator and semantics for producing state in a lifecycle aware manner.

stateIn makes registering intent to produce state explicit. State should not be produced when the lifecycle of the screen is not active, and the started argument when creating a StateFlow is critical for this. The docs recommend SharingStarted.WhileSubscribed with a timeout of 5 seconds to gracefully allow for the enqueuing of background work or other activities when the lifecycle is stopped (destroyed lifecycles have no grace period).

Ultimately, with compose state, we need a way to register intent to the state production pipeline to only produce state when the lifecycle is active. The good news is this is fairly easy to do, and can be done with a utility method and the existing SharingStarted coroutine APIs.

Define general state production

To register intent for the production of state, your state holder (a plain class or ViewModel) needs to expose a suspending method that suspends the caller indefinitely until the caller itself cancels. This acts as a "heartbeat" signal. I tend to call this method produceState as it has symmetry with the existing produceState Composable.

class AddEditTaskUiStateViewModel : ViewModel() { val state: AddEditTaskUiState get() = mutableState private val mutableState = AddEditTaskUiState.Immutable().toSnapshotMutable() suspend fun produceState() { ... } }

Define lifecycle aware state production

To make the heart actually beat, coroutine, compose and lifecycle APIs can be combined to define a produceStateWithLifecycle() method. In the definition below, it's added as an extension on AddEditTaskUiStateViewModel, but in a real world app, this would need to be extracted to a reusable function for each state holder.

@Composable fun AddEditTaskUiStateViewModel.produceStateWithLifecycle( lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle, minActiveState: Lifecycle.State = Lifecycle.State.STARTED, context: CoroutineContext = EmptyCoroutineContext, ): AddEditTaskUiState { val scope = rememberCoroutineScope() DisposableEffect(this, scope, lifecycle, minActiveState, context) { val job = scope.launch { lifecycle.repeatOnLifecycle(minActiveState) { if (context == EmptyCoroutineContext) produceState() else withContext(context) { produceState() } } } onDispose(job::cancel) } return state }

Implement state production

The produceState block in the state holder is where state production is assembled. This single method, has to implement all the semantics for processing all inputs in state production summarized in the image below from the official docs:

The state production pipeline. Input -> processing -> output.

To simplify things, let's eschew user events and just focus on sources of state change independent of the user. The produceState method in AddEditTaskUiStateViewModel can be defined as:

@Inject class AddEditTaskUiStateViewModel( private val tasksRepository: TasksRepository, @Assisted private val taskId: String, ) : ViewModel() { val state: AddEditTaskUiState get() = mutableState private val mutableState = AddEditTaskUiState.Immutable().toSnapshotMutable() suspend fun produceState() { coroutineScope { launch { tasksRepository.task(taskId).collect { task -> mutableState.title = task.name mutableState.description = task.description } } launch { tasksRepository.taskStatus(taskId).collect { isCompleted -> mutableState.isTaskCompleted = isCompleted } } } } }

In it, a child coroutine of the caller of produceState is created with coroutineScope, and several coroutines are launched inside it to collect from as many flows as needed. There is no need for the Flow combine operator.

Each Flow collector simply writes to snapshot mutable state without needing to use Snapshot.withMutableSnapshot as the parent CoroutineScope collects on the main thread.

Consume produced state in the UI

The output of the state production above can now simply be read in the UI:

@Composable fun AddEditTaskUiScreen( modifier: Modifier = Modifier, viewModel: AddEditTaskUiStateViewModel, ) { val state = viewModel.produceStateWithLifecycle() Column( modifier = modifier, ) { Text(state.title) Text(state.description) } }

The CoroutineScope as the state production scope

There are two things missing from the code snippets so far:

Handling these two requires explicitly recognizing the role of the CoroutineScope in state production, and its developer ergonomics relies heavily on a Kotlin 2.4 feature: Context parameters.

Sharing Started

In the example above, the second the lifecycle is stopped, state production terminates. We need a way to use the SharingStarted API independent of StateFlow or SharedFlow. First, let's classify the different CoroutineScope instances involved:

  • The state holder scope: In a ViewModel, this is the viewModelScope. For a UI logic state holder, it is usually provided by rememberCoroutineScope(). Canceling this scope terminates state production entirely, therefore it is only ever canceled when the UI it backs is no longer reachable.

  • The state production scope: This is the scope that exists as long as there is something actually consuming the state, i.e there's a heart beat.

It is the latter scope that we can harness with SharingStarted to get behavior equivalent to StateFlow. Consider the following snippet:

fun <State : Any> CoroutineScope.produceState( state: State, started: SharingStarted = SharingStarted.WhileSubscribed(DEFAULT_STOP_TIMEOUT_MILLIS), producer: suspend CoroutineScope.(State) -> Unit, ): suspend () -> Unit { val subscriptionCount = MutableStateFlow(0) val produceState: suspend () -> Unit = { try { subscriptionCount.update { it + 1 } // suspend indefinitely until the caller cancels awaitCancellation() } finally { subscriptionCount.update { it - 1 } } } launch { var producerJob: Job? = null started.command(subscriptionCount).collect { command -> when (command) { SharingCommand.START -> { if (producerJob == null || producerJob?.isActive == false) { producerJob = launch { producer(state) } } } SharingCommand.STOP, SharingCommand.STOP_AND_RESET_REPLAY_CACHE, -> { producerJob?.cancel() producerJob = null } } } } return produceState }

It is a little dense, so let's discuss what it is. It is a single method that describes a generic state production pipeline, and returns a handle to actually start producing the state. The owning CoroutineScope launches a coroutine that monitors start commands from the specified SharingStarted and manages state production based on its signal. Crucially, the SharingStarted method is the true arbiter for when state production starts or stops, in partnership with the heart beat described above. The heart beat is defined by the invocation of the lambda returned.

With the above, we can now rewrite AddEditTaskUiStateViewModel with full support for a SharingStarted appropriate for a ViewModel supporting a navigation destination:

@Inject class AddEditTaskUiStateViewModel( private val tasksRepository: TasksRepository, @Assisted private val taskId: String, ) : ViewModel() { val state: AddEditTaskUiState get() = mutableState private val mutableState = AddEditTaskUiState.Immutable().toSnapshotMutable() private val stateProducer = viewModelScope.produceState( state = mutableState, started = SharingStarted.WhileSubscribed(DEFAULT_STOP_TIMEOUT_MILLIS), producer = { launch { tasksRepository.task(taskId).collect { task -> mutableState.title = task.name mutableState.description = task.description } } launch { tasksRepository.taskStatus(taskId).collect { isCompleted -> mutableState.isTaskCompleted = isCompleted } } } ) suspend fun produceState() = stateProducer() } private val DEFAULT_STOP_TIMEOUT_MILLIS = 5_000L

Each launch block inside the producer block above can be simplified with context parameters:

context(scope: CoroutineScope) inline fun <T> Flow<T>.launchAndCollect( crossinline block: suspend (T) -> Unit, ) { scope.launch { collect { block(it) } } }

So we can simplify and write the state producer definition above as:

private val stateProducer = viewModelScope.produceState( state = mutableState, started = SharingStarted.WhileSubscribed(DEFAULT_STOP_TIMEOUT_MILLIS), producer = { tasksRepository.task(taskId).launchAndCollect { task -> mutableState.title = task.name mutableState.description = task.description } tasksRepository.taskStatus(taskId).launchAndCollect { isCompleted -> mutableState.isTaskCompleted = isCompleted } }, )

I'd be remiss if I didn't point out the similarity between the signature of CoroutineScope.produceState and that of produceState from Jetpack Compose.

// Coroutine produceState fun <State : Any> CoroutineScope.produceState( state: State, started: SharingStarted, producer: suspend CoroutineScope.(State) -> Unit, ): suspend () -> Unit // Compose produceState @Composable fun <T : Any?> produceState(initialValue: T, producer: suspend ProduceStateScope<T>.() -> Unit): State<T>

In both methods, state production is simply defined as a suspending lambda with a backing CoroutineScope as a receiver. The symmetry above is the major part of the final section below.

User Input

If you've read this far, I'll assume I've earned enough of your time and trust for another opinion: you should model user inputs with an Action sealed class hierarchy.

The irony in lamenting object allocation in immutable data classes for state production, yet advocating for it for user actions in the same breath is not lost on me. However, consider that user actions are sent far less frequently than new state is produced, and no UI diffs the sent action.

More importantly, CoroutineScope.produceState is a closed loop. User actions have to be part of its input for it to have any effect on state production. The only way that happens with the API shape defined thus far is if the actions were defined as a Channel that can be sent to, and collected from like other flows from repositories and data sources. For those who insist on method calls of the form:

private fun createNewTask() { viewModelScope.launch { val newTask = Task(uiState.value.title, uiState.value.description) try { tasksRepository.saveTask(newTask) // Write data into the UI state. _uiState.update { it.copy(isTaskSaved = true) } } catch(cancellationException: CancellationException) { throw cancellationException } catch(exception: Exception) { _uiState.update { it.copy(userMessage = getErrorMessage(exception)) } } } }

There's a very apt name for this, though I can't find the author at the moment. Its trashcan concurrency, because the coroutine launched is yeeted to the ether. The only semblance of control over it is the returned Job from CoroutineScope.launch, but most code blocks similar to the above, ignore that job. Worse yet, for all the fuss this document has made about honoring SharingStarted, how can the createNewTask method above be constrained to run in a CoroutineScope constrained by SharingStarted? The only CoroutineScope handle available above is the ViewModel scope (state holder scope), not the state production scope.

The more I look at it, the more I'm convinced that blocks of code like the above should only exist if they are strictly synchronous or gated with tools like a Compose MutatorMutex for UI code where the coroutine and state holder cannot outlive its caller. i.e, it's impossible for a tree to fall and no one hears it.

Of course, you could write a library that captures the current state production scope at any one time and makes it available to methods. I imagine folks who would do that also enjoy swimming upstream. Opinions are easy to have, but I actually have skin in the game on this. Heron is my production app where I practice what I preach. A ViewModel in Heron typically looks like the following:

@Stable @AssistedInject class ActualEditProfileViewModel( authRepository: AuthRepository, profileRepository: ProfileRepository, recordRepository: RecordRepository, fileManager: FileManager, writeQueue: WriteQueue, navActions: (NavigationMutation) -> Unit, @Assisted scope: CoroutineScope, @Assisted route: Route, ) : ViewModel(viewModelScope = scope), EditProfileStateHolder by scope.actionSuspendingStateMutator( state = State(route).toSnapshotMutable(), started = SharingStarted.WhileSubscribed(FeatureWhileSubscribed), producer = { state, actions -> launchLoadProfileMutations( state = state, authRepository = authRepository, ) launchPendingUpdateSubmissionMutations( state = state, writeQueue = writeQueue, ) launchProfileTabMutations( state = state, route = route, profileRepository = profileRepository, ) launchScreenTabMutations( state = state, viewModelScope = scope, authRepository = authRepository, recordRepository = recordRepository, ) actions.launchMutationsIn( productionScope = this, keySelector = Action::key, ) { when (val action = type()) { is Action.Navigate -> action.flow.collect { navActions(it.navigationMutation) } is Action.AvatarPicked -> action.flow.launchAvatarPickedMutations(state) is Action.BannerPicked -> action.flow.launchBannerPickedMutations(state) is Action.FieldChanged -> action.flow.launchFormEditMutations(state) is Action.SnackbarDismissed -> action.flow.launchSnackbarDismissalMutations(state) is Action.UpdateTabsToSave -> action.flow.launchPinnedTabMutations(state) is Action.ToggleFeed -> action.flow.launchToggleFeedMutations(state) is Action.SaveProfile -> action.flow.launchSaveProfileMutations( state = state, navActions = navActions, writeQueue = writeQueue, fileManager = fileManager, ) } } }, )

The above is a condensed form of everything I've written so far. launchPendingUpdateSubmissionMutations for example is:

context(productionScope: CoroutineScope) private fun launchPendingUpdateSubmissionMutations( state: State.SnapshotMutable, writeQueue: WriteQueue, ) = writeQueue.queueChanges.launchAndCollect { writes -> state.submitting = writes.any { it is Writable.ProfileUpdate } }

For a user action, launchAvatarPickedMutations is:

context(productionScope: CoroutineScope) private fun Flow<Action.AvatarPicked>.launchAvatarPickedMutations( state: State.SnapshotMutable, ) = launchAndCollect { action -> state.updatedAvatar = action.item }

In ActualEditProfileViewModel:

  • I safely produce state with compose snapshot state.

  • I use SharingStarted to make sure navigating away from a screen that's still in the backstack does not stop my coroutines until after a set period.

  • I enjoy the advantages of structured concurrency with coroutines without needing to write complex Flow operators, or combining, merging, or what have you.

Conclusion

We want to write simple code, but concurrency is hard. We've made improvements year over year, and today with Kotlin, coroutines and compose, we've come the closest yet to writing simple yet powerful code.

If you are producing state for a Compose snapshot state consumer and you're using StateFlow, consider what exactly it is you're trying to achieve, and ask yourself, would this be better served by an interface where an implementation simply delegated to snapshot State? I have my biases, however I think the answer is mostly going to be yes.

Ty Adam & Matt for proofreading.

,