Understand what Unidirectional Data Flow is — a unidirectional data stream, an architectural pattern where data moves in a closed loop State → View → Intent → Reducer → State without feedback loops. Unlike two-way binding, UDF guarantees that state changes happen only through explicit actions (Intent/Event), making the data flow predictable and traceable. According to Google I/O 2024, UDF is the recommended architecture for Jetpack Compose and SwiftUI applications with medium and high complexity business logic.
Key Takeaways
Unidirectional Data Flow (UDF) is an architectural pattern where data moves in one direction in a closed loop, eliminating feedback loops between View and Model. Unlike Two-Way Binding, where a change in the UI immediately updates the model, UDF requires an explicit action (Intent, Event, Action) for every state change. This makes the data flow completely predictable: at any point in time, you can determine which action led to the current state.
The UDF concept originated from web frameworks — Redux (JavaScript, 2015) and Elm (2012) — and was adapted for mobile development. According to Google I/O 2024, UDF has become the recommended architecture for Jetpack Compose, replacing the classic MVVM with LiveData. On iOS, a similar approach is implemented in The Composable Architecture (TCA) by Point-Free, which is used by over 15% of iOS developers according to the Swift Community Survey (2024).
The main advantage of UDF is Single Source of Truth (SSOT): all application state is stored in one place and modified through strictly defined operations. This simplifies debugging, testing, and bug reproduction, since every state change is logged and can be reproduced by resending the same Intents.
The basic UDF cycle consists of four steps: State (current state) is rendered in View; the user performs an action that becomes an Intent; the Intent is processed in a Reducer (pure function), which creates a new State; the new state is passed to the View for re-rendering. This cycle repeats on every user or system event.
Each element of the cycle has a strict responsibility: State — an immutable object describing the screen state at a specific moment; View — a function that renders the State; Intent — a value describing the user's intention (e.g., LoginIntent.Submit); Reducer — a pure function with no side effects, taking the current State and Intent and returning a new State. Side effects (network requests, database operations) are moved to a separate Middleware or Effect layer.
According to the Google Android Architecture article (2024), Reducer purity is a key requirement: if a Reducer contains a network call or database write, testing and debugging the data flow becomes impossible. All side effects must be executed in a ViewModel coroutine or Swift Task before calling the Reducer, and the result must be sent as a new Intent.
On Android, UDF implementation is built on three Jetpack components: ViewModel manages the lifecycle, StateFlow provides a reactive state stream, Intent (sealed class) describes all possible user actions. The View subscribes to StateFlow via collectAsState() in Compose or observe() in the View system.
sealed class LoginIntent {
data object Submit : LoginIntent()
data class UpdateEmail(val value: String) : LoginIntent()
data class UpdatePassword(val value: String) : LoginIntent()
}
data class LoginState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
class LoginViewModel : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
fun onIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.UpdateEmail -> {
_state.update { it.copy(email = intent.value) }
}
is LoginIntent.UpdatePassword -> {
_state.update { it.copy(password = intent.value) }
}
is LoginIntent.Submit -> {
_state.update { it.copy(isLoading = true, error = null) }
loginUseCase(_state.value.email, _state.value.password)
.onSuccess {
_state.update { it.copy(isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
}
}
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsState()
LoginForm(
email = state.email,
onEmailChange = { viewModel.onIntent(LoginIntent.UpdateEmail(it)) },
password = state.password,
onPasswordChange = { viewModel.onIntent(LoginIntent.UpdatePassword(it)) },
isLoading = state.isLoading,
onSubmit = { viewModel.onIntent(LoginIntent.Submit) }
)
}The example demonstrates the full UDF cycle in Android: LoginIntent describes all possible actions (email change, password change, form submission), LoginState is the immutable state, LoginViewModel processes Intents and updates StateFlow, and the Compose screen subscribes to state via collectAsState(). Every state change is the result of processing a specific Intent, making the data flow completely transparent.
On iOS, UDF is implemented through The Composable Architecture (TCA) by Point-Free or the native Observable pattern with iOS 17+. TCA provides a ready-made cycle of State + Action + Reducer + Store, where Store is the single source of truth, and the View subscribes to changes via @Observable or ObservableObject.
struct LoginState: Equatable {
var email = ""
var password = ""
var isLoading = false
var error: String?
}
enum LoginAction {
case emailChanged(String)
case passwordChanged(String)
case submit
case loginResponse(Result<User, Error>)
}
let loginReducer = Reducer<LoginState, LoginAction> { state, action in
switch action {
case .emailChanged(let email):
state.email = email
return .none
case .passwordChanged(let password):
state.password = password
return .none
case .submit:
state.isLoading = true
state.error = nil
return .run { send in
let result = await loginUseCase(state.email, state.password)
await send(.loginResponse(result))
}
case .loginResponse(.success):
state.isLoading = false
return .none
case .loginResponse(.failure(let error)):
state.isLoading = false
state.error = error.localizedDescription
return .none
}
}
struct LoginView: View {
let store: StoreOf<LoginReducer>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
Form {
TextField("Email", text: viewStore.binding(get: \.email, send: { .emailChanged($0) }))
SecureField("Password", text: viewStore.binding(get: \.password, send: { .passwordChanged($0) }))
Button("Log In") { viewStore.send(.submit) }
}
}
}
}The loginReducer is a pure function: it does not perform network requests directly but returns an Effect that will be executed by the TCA runtime. This allows testing the reducer in isolation by substituting effects in tests. The View subscribes to Store changes through WithViewStore and sends Actions via send(). TCA automatically handles effect cancellation when the Store is destroyed, preventing memory leaks.
MVVM and UDF are often confused, but there is a fundamental difference between them. MVVM is a structural pattern that separates code into three layers (Model, View, ViewModel) but does not define the direction of data flow. UDF is a behavioral pattern that describes how data moves within that structure. In MVVM with LiveData, both two-way binding and unidirectional flow are possible — UDF adds strict Intent processing rules to MVVM.
According to Android Developers documentation (2024), the recommended architecture for Compose is UDF within MVVM: ViewModel stores State and processes Intents, View subscribes to State and sends Intents. Google recommends classic MVVM with Two-Way Binding through DataBinding only for simple screens without business logic. For Jetpack Compose, the primary scenario is UDF with explicit event handling.
Comparison table:
| Characteristic | MVVM (classic) | MVVM + UDF |
|---|---|---|
| Data flow | Not defined | Strictly unidirectional |
| State change | Directly via setText() | Only through Intent → Reducer |
| Single Source of Truth | No | Yes |
| Reducer testability | Low | High (pure function) |
| Google recommendation | Legacy approach | Primary for Compose |
The most common mistake is side effects inside the Reducer. Developers accustomed to MVVM place network requests directly in the Intent handler, making the Reducer impure and breaking testability. All effects should be returned as a value (Effect / SideEffect) and executed by the framework infrastructure. On Android, coroutines in ViewModel are used for this; in TCA — Effect.run.
The second mistake is overly granular Intents. Every keystroke, slider movement, and text change generates a separate Intent. For input fields this is excessive — in such cases, it's acceptable to use Binding with a one-way flow within the form (local state), and send a global Intent only for significant actions (submit, navigation).
The third mistake is lack of effect cancellation handling. If the user leaves the screen while a coroutine or Task is still executing, the result may be applied to a destroyed View. On Android, use viewModelScope.cancel() or takeWhileActive(); in TCA, effects are automatically cancelled when the Store is destroyed. According to Google Issue Tracker (2024), leaks from incomplete coroutines are among the top 5 causes of crashes in Compose applications.
Frequently Asked Questions
MVI (Model-View-Intent) is a specific case of UDF with three mandatory elements: Intent (intention), Model (state), View (display). The main difference is that in MVI, every screen state is described by a single immutable structure (Sealed class), and View is a pure function from Model to UI. UDF is a broader term describing any unidirectional flow, including Redux and Elm. In Google's documentation, the term UDF is used as a general name, while MVI is a specific implementation.
UDF is overkill for screens with a single input field without validation, static pages, and placeholder screens. If a screen has no business logic and its state does not depend on user actions, UDF adds unnecessary code without benefit. For such scenarios, one-way binding or simple @State in SwiftUI is sufficient. UDF is justified when the number of possible screen states exceeds 3–4 and/or side effects are present.
Since the Reducer is a pure function, testing it comes down to calling it with different combinations of State and Intent and checking the resulting State and Effect. On Android, use Turbine for testing StateFlow: send an Intent, check the next State emission. In TCA, there is a built-in TestStore that automatically verifies that after an Action only the expected State fields changed and only the expected Effects were executed.
Yes, combining them is acceptable and often optimal. For input fields inside a form, use local Two-Way Binding (or Binding in SwiftUI) to avoid creating an Intent on every keystroke. On form submission, send a single Intent with the collected data, which is processed by the Reducer. This hybrid approach — global UDF with local Two-Way Binding — is used in 70% of commercial SwiftUI applications (Swift Community Survey 2024 data).
All three patterns implement unidirectional data flow with a single source of truth. Elm (2012) — a functional language — first introduced the pure Model → View → Update cycle. Redux (2015) adapted Elm for JavaScript with the concepts of Store, Reducer, and Action. UDF is a generalization of these ideas for mobile development. All three approaches guarantee predictability of changes through atomic state updates.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also