Unidirectional Data Flow — What It Is, UDF in Android and iOS

Author: IT Sectr Published: 2026-02-20 Reading time: 13 min

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) — an architectural pattern where state changes strictly follow the cycle: user input → Intent → Reducer → new State → View re-render.
  • On Android, UDF is implemented via ViewModel + StateFlow + Intent handling; on iOS — via @Observable + Reducer pattern (Composable Architecture).
  • Google recommends UDF as the primary architecture for Jetpack Compose, starting with the 2023 documentation.
  • UDF eliminates the infinite loop problem of Two-Way Binding through a Single Source of Truth.
  • The main drawback is more boilerplate code compared to two-way binding (State, Intent, Reducer, Effect).

What Is Unidirectional Data Flow?

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.

How UDF Works: State → View → Intent → Reducer Cycle

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.

UDF in Android: ViewModel + StateFlow + 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.

Kotlin
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.

UDF in iOS: TCA and Observable Pattern

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.

Swift
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.

UDF vs MVVM: What's the Difference?

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:

CharacteristicMVVM (classic)MVVM + UDF
Data flowNot definedStrictly unidirectional
State changeDirectly via setText()Only through Intent → Reducer
Single Source of TruthNoYes
Reducer testabilityLowHigh (pure function)
Google recommendationLegacy approachPrimary for Compose

Common Mistakes When Implementing UDF

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

How is UDF different from MVI?

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.

When is UDF overkill?

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.

How to test UDF?

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.

Can UDF and Two-Way Binding be combined?

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).

What do UDF, Redux, and Elm have in common?

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

  • Unidirectional Data Flow (UDF) is a pattern with a unidirectional State → View → Intent → Reducer cycle, ensuring predictable state changes.
  • On Android, UDF is implemented via ViewModel + StateFlow + sealed class Intent; on iOS — via TCA (Reducer + Store) or native Observable.
  • Google recommends UDF as the primary architecture for Jetpack Compose, starting from 2023.
  • The Reducer is a pure function without side effects; all network requests and database operations are moved to the Effect layer.
  • UDF eliminates the infinite loop problem of Two-Way Binding through explicit Intent handling and a Single Source of Truth.
  • Main risks — side effects in the Reducer, overly granular Intents, and incomplete coroutines.
  • A hybrid approach (local Two-Way Binding in forms + global UDF) is optimal for most commercial applications.

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.

Discuss the project

Read also