Unidirectional Data Flow Without the Boilerplate Tax
UDF earns its keep; the canonical MVI scaffolding around it often doesn't. The four properties that actually make unidirectional data flow work, the three pieces of ceremony you can delete, and where the sealed-intent version genuinely pays off.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Unidirectional data flow is worth keeping: one immutable state object per screen, replaced
atomically, with events travelling only upward and one-shot effects in a Channel rather than in
state. The sealed Intent hierarchy, the generic Store base class, and the standalone reducer are
a separate choice, and for most screens they cost more than they return. A public method on a ViewModel
is already a unidirectional event. Adopt the intent type when you need to record, replay or serialise
the event stream — not by default.
What UDF actually buys you
Before deleting anything, be precise about what's load-bearing. Four properties:
One state object. A screen's entire visual truth is one data class. Not isLoading, error,
items and isRefreshing as four separate StateFlows that can be simultaneously in states the
designer never drew.
data class LoginUiState(
val email: String = "",
val password: String = "",
val submitting: Boolean = false,
val error: String? = null,
) {
val canSubmit: Boolean get() = email.isNotBlank() && password.length >= 8 && !submitting
}
Note canSubmit as a derived property. It cannot drift out of sync with its inputs, because it isn't
stored — which is the entire argument for one state object, in miniature.
Replaced, never mutated. And replaced atomically:
// Read-modify-write: two concurrent callers, one lost update
_state.value = _state.value.copy(submitting = true)
// Atomic — retries on conflict
_state.update { it.copy(submitting = true) }
update is not a style preference. Two coroutines updating different fields of the same state within
the same frame will silently lose one of the writes with .value =, and you'll spend an afternoon on
it. This is the single most common real bug in hand-rolled UDF.
Events travel one way. The UI reports what happened; it never writes state. Composables take state and lambdas, not a ViewModel.
One-shot effects aren't state. "Navigate to home" and "show a snackbar" are not properties of the screen — put them in state and they replay on rotation, giving you the classic double-navigation bug.
private val _effects = Channel<LoginEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
Those four are UDF. Everything below is optional.
The ceremony, and what it costs
Here's the shape that usually ships alongside:
sealed interface LoginIntent {
data class EmailChanged(val value: String) : LoginIntent
data class PasswordChanged(val value: String) : LoginIntent
data object Submit : LoginIntent
}
class LoginReducer : Reducer<LoginUiState, LoginIntent> {
override fun reduce(state: LoginUiState, intent: LoginIntent) = when (intent) {
is LoginIntent.EmailChanged -> state.copy(email = intent.value)
is LoginIntent.PasswordChanged -> state.copy(password = intent.value)
LoginIntent.Submit -> state.copy(submitting = true)
}
}
class LoginViewModel(reducer: LoginReducer) : BaseStore<LoginUiState, LoginIntent, LoginEffect>(...)
Three files, a base class, and a when block whose entire purpose is to turn a type back into the
operation you already had. And the same behaviour:
class LoginViewModel : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state = _state.asStateFlow()
fun onEmailChanged(value: String) = _state.update { it.copy(email = value) }
fun onPasswordChanged(value: String) = _state.update { it.copy(password = value) }
fun submit() { /* ... */ }
}
viewModel.onEmailChanged(text) is not less unidirectional than dispatch(EmailChanged(text)).
It's the same arrow — UI → ViewModel — with the type-erasure step removed. State still flows down and
only down; the UI still can't mutate anything.
The costs of the longer version are real and they compound:
- Every new interaction touches three files, so people batch changes or avoid them.
- Stack traces route through the dispatcher, so "who set submitting = true" is a search, not a click.
- The generic
Store<S, I, E>forces eleven screens into the shape of the most complex one. A settings toggle inherits the machinery of a checkout flow. - Code review shifts to the plumbing, because the plumbing is most of the diff.
When the sealed intent genuinely pays
I'm not arguing it's always wrong. It buys something specific: the event stream becomes data. Take it when you actually consume that:
- Replay and time-travel debugging. Serialise the intent log, attach it to a bug report, replay it. For a complex flow — a multi-step form, a checkout, a player — this is a real superpower.
- A screen with genuine state-machine behaviour, where the same event means different things in
different states and illegal transitions must be rejected. Exhaustive
whenover (state × intent) is the right tool, and the compiler checks it. - Cross-cutting middleware — logging every user action to analytics, or a global undo. One interception point beats thirty call sites.
- Shared logic across platforms. A KMP reducer is pure, testable, and portable in a way that ViewModel methods aren't.
The pattern: if nothing ever inspects the intent objects other than the single when that consumes
them, they're an indirection with no reader.
The middle ground most screens want
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
private val _state = MutableStateFlow(FeedUiState())
val state = _state.asStateFlow()
private val _effects = Channel<FeedEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
fun refresh() {
viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null) }
repo.load()
.onSuccess { items -> _state.update { s -> s.copy(items = items, refreshing = false) } }
.onFailure { e -> _state.update { it.copy(refreshing = false, error = e.userMessage()) } }
}
}
fun onItemClicked(id: String) {
viewModelScope.launch { _effects.send(FeedEffect.OpenDetail(id)) }
}
}
Roughly 25 lines. It has every UDF property: one state object, atomic replacement, upward-only events,
effects out of band. It has no reducer, no intent type, no base class, and the screen is completely
testable — set up a fake repository, call refresh(), assert on state.value.
And the composable stays dumb, which is what makes it previewable:
@Composable
fun FeedScreen(state: FeedUiState, onRefresh: () -> Unit, onItemClick: (String) -> Unit) { ... }
The test that settles the argument
When someone says an architecture isn't "properly unidirectional", ask the question that actually matters:
Can I tell every state this screen can be in, by reading one data class? And is there exactly one place that changes it?
Yes to both is UDF. The number of sealed interfaces in the module is not the score, and neither is the
line count of the BaseStore someone copied in from a conference talk.
The rule
Adopt the constraint, not the ceremony. Unidirectional data flow is four properties, and you can have all four in a 25-line ViewModel. Add the intent type the day you have a second reader for it — replay, a real state machine, middleware, a shared KMP reducer — and not one screen earlier.