7 min read

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.

AndroidArchitectureMVIJetpack ComposeState Management
HR

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 when over (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.

Keep reading