UI State, UI Events, and One-Time Effects in Compose

Quick answer: Render a durable
UiState, send user intent upward as explicit UI events, and run transient work—such as a snackbar or navigation—from a controlled effect. Do not treat a one-off command as persistent screen state, and never start it directly in a composable body.
This split gives a Compose screen a predictable loop: state down, events up, effects out. Android’s Compose architecture guidance describes the same unidirectional flow: UI events reach the state holder, which updates state for the UI to render.
Classify each value before choosing an API
| Kind | Example | Model it as |
|---|---|---|
| Durable UI state | form fields, loading, visible error, selected item | immutable UiState |
| UI event | tap Save, retry, edit text | callback or event function |
| One-time effect | show snackbar, navigate, launch a picker | effect handled in a lifecycle-aware composition scope |
If an app is recreated and the UI should still show the value, it is usually state. If it is an instruction that should happen once, it is an effect. A snackbar can be a consequence of durable error state or a one-time command; choose based on the product behavior, not the component name.
Keep rendered state immutable
data class EditProfileUiState(
val name: String = "",
val isSaving: Boolean = false,
val inlineError: String? = null,
)
sealed interface EditProfileEvent {
data class NameChanged(val value: String) : EditProfileEvent
data object SaveClicked : EditProfileEvent
}The state contains only what the screen can render. The events name an intent, rather than allowing a child to mutate a ViewModel field. That makes state ownership clear and lets a stateless screen be previewed or tested with fixed values. See state hoisting with real examples for the ownership rule.
Let the ViewModel reduce events into state
class EditProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow(EditProfileUiState())
val uiState = _uiState.asStateFlow()
fun onEvent(event: EditProfileEvent) {
when (event) {
is EditProfileEvent.NameChanged -> {
_uiState.update { it.copy(name = event.value, inlineError = null) }
}
EditProfileEvent.SaveClicked -> save()
}
}
private fun save() {
// Validate, call the use case, then update UiState.
}
}For Android screens, collect the state with lifecycle-aware StateFlow collection and pass it down with onEvent. The UI has one durable source of truth instead of local copies that drift apart.
Send transient commands through a dedicated effect stream
For a command that must not become a permanent part of the rendering model, expose a separate stream. A Channel is one possible design when a single UI consumer should receive each command.
sealed interface EditProfileEffect {
data object NavigateBack : EditProfileEffect
data class ShowMessage(val text: String) : EditProfileEffect
}
class EditProfileViewModel : ViewModel() {
private val effectsChannel = Channel<EditProfileEffect>(Channel.BUFFERED)
val effects = effectsChannel.receiveAsFlow()
private fun onSaveSucceeded() {
viewModelScope.launch {
effectsChannel.send(EditProfileEffect.ShowMessage("Profile saved"))
effectsChannel.send(EditProfileEffect.NavigateBack)
}
}
}There is no universal event-stream choice. A Channel, SharedFlow, or a state-driven approach has different delivery and replay behavior. Decide deliberately what should happen if no UI is collecting, if a screen rotates, or if two collectors exist; then test that policy.
Handle effects from LaunchedEffect
@Composable
fun EditProfileRoute(
viewModel: EditProfileViewModel = viewModel(),
onNavigateBack: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(viewModel.effects) {
viewModel.effects.collect { effect ->
when (effect) {
EditProfileEffect.NavigateBack -> onNavigateBack()
is EditProfileEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.text)
}
}
}
EditProfileScreen(uiState = uiState, onEvent = viewModel::onEvent)
}LaunchedEffect gives the collection a composition-bound coroutine; it is cancelled when it leaves composition. The side-effects documentation calls out navigation and snackbar work as effects that need a controlled environment.
Never call showSnackbar(), navigate, or launch work directly while rendering a composable. Recomposition may run more than once or be discarded, so rendering must stay side-effect free.
Common mistakes
Put a one-off success message in UiState
If successMessage stays populated, a recreated collector can show it again. Clearable state can be correct when the message is intentionally visible and acknowledged by the UI; otherwise prefer an effect with a tested delivery policy.
Trigger navigation from an if in the body
if (uiState.isComplete) onNavigateBack() is unsafe because composition is not a one-time callback. Drive navigation through an effect, or model navigation as durable state only when restoring it is the intended behavior.
Model every click as an effect
A click is an input event. Its result might update rendered state, emit an effect, or do both. Keeping those stages separate makes error and retry behavior easier to reason about.
A practical decision rule
- Can the UI render it after rotation or process recreation? It is state; decide how to save or reload it.
- Did the user or another layer request work? It is an event.
- Does it command something outside rendering once? It is an effect and needs controlled collection.
For saveable UI values, see rememberSaveable and custom savers. For transient feedback that comes directly from a click, the Snackbar guide shows the simpler rememberCoroutineScope event-handler pattern.
FAQ
Should every ViewModel expose an effect flow?
No. Use one only when there is a genuinely transient command with a clear delivery policy. A screen that only renders state and sends events does not need another stream.
Can an error be both state and an effect?
Yes. A field-validation error may be durable state while an unexpected failed save emits a snackbar. Model each user-visible consequence according to whether it must persist.