๐Ÿ›๏ธ Android Architecture and Clean Code

Unidirectional Data Flow in Android

Unidirectional data flow is the principle underneath MVVM, MVI and Compose state hoisting: state flows one way, events flow the other, and each piece of data has exactly one owner. Understand UDF once and every Android architecture pattern makes more sense.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. The rule
  2. UDF end to end
  3. Benefits
  4. Common UDF violations
  5. UDF at different scales
  6. Frequently asked questions
  7. Conclusion

In a bidirectional design, a text field updates a variable, the variable updates a label, the label's listener updates another variable... and nobody can say which value is "correct" when they disagree. Unidirectional data flow (UDF) removes that ambiguity with one rule: state flows down, events flow up.

The rule

  • State is owned by a single source of truth and flows down to consumers, which can only read it.
  • Events (user actions, system callbacks) flow up to the owner, which decides how state changes.
  • The owner produces a new immutable state, which flows down again.
Unidirectional data flow across the app layers A loop: the data layer exposes data to the ViewModel; the ViewModel produces UI state to the screen; the screen passes state to components; a user tap in a component sends an event up to the screen, then to the ViewModel, which may write to the data layer; new data flows down again. Data layer ViewModel Screen composable Component (button) state โ†“ events โ†‘
The same shape repeats at every level: data layer to ViewModel, ViewModel to screen, screen to component.

UDF end to end

Like button: one source of truthKotlin
// Data layer owns the truth
class PostRepository @Inject constructor(private val dao: PostDao, private val api: PostApi) {
    fun observePost(id: String): Flow<Post> = dao.observe(id).map { it.toDomain() }
    suspend fun setLiked(id: String, liked: Boolean) {
        dao.setLiked(id, liked)               // optimistic local update
        runCatching { api.setLiked(id, liked) }.onFailure { dao.setLiked(id, !liked); throw it }
    }
}

// ViewModel turns data into UI state and handles events
class PostViewModel(private val repo: PostRepository, postId: String) : ViewModel() {
    private val id = postId
    val uiState = repo.observePost(id)
        .map { PostUiState(title = it.title, liked = it.liked, likeCount = it.likeCount) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PostUiState())

    fun onLikeClick() = viewModelScope.launch {
        runCatching { repo.setLiked(id, !uiState.value.liked) }
    }
}

// UI renders state and emits events; it never flips `liked` itself
@Composable
fun LikeButton(liked: Boolean, count: Int, onClick: () -> Unit) {
    TextButton(onClick = onClick) {
        Icon(if (liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, contentDescription = null)
        Text(" $count")
    }
}

Even the optimistic update goes through the database, so every screen showing this post agrees.

Benefits

  • Consistency: one owner per piece of data means no conflicting copies.
  • Predictability: state changes happen in known places, so bugs are easier to trace.
  • Testability: feed events in, assert state out.
  • Survives configuration changes, because state lives in ViewModels and repositories rather than Views.

Common UDF violations

ViolationSymptomFix
UI mutates shared state directly (public MutableStateFlow)State changes from unexpected placesExpose read-only StateFlow; mutate via ViewModel functions
Local copy of ViewModel state in a composableUI and ViewModel disagreeRead directly from state; hoist instead of copying
Two ViewModels both caching the same dataScreens show different valuesMove the truth to the data layer and observe it
Child components calling repositoriesHidden data flows, hard testsEvents up to the screen's ViewModel

UDF at different scales

Component level

State hoisting: value plus onValueChange. See State Hoisting.

Screen level

ViewModel exposes UiState, and the screen calls functions. See ViewModel and StateFlow.

App level

The database as source of truth, with network sync writing into it. See Offline-First.

Frequently asked questions

Is UDF the same as MVI?

MVI is a strict form of UDF that formalises events as intents and changes as a reducer. MVVM with a single immutable state is also UDF.

Doesn't UDF add latency for text input?

Not noticeably when state updates are synchronous on the main thread, as with MutableStateFlow.update or Compose state. Avoid asynchronous round-trips for every keystroke.

Where do one-off events fit?

Model them as state that the UI consumes and clears, so they still follow the unidirectional loop.

Conclusion

UDF is a simple discipline: one owner per piece of state, state down as immutable values, events up as function calls. Apply it at component, screen and app level and your Android code becomes predictable, consistent and testable.

Next step: scale the codebase physically with Multi-Module Architecture in Android.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Architecture makes the most sense when you apply it to a real project and see why each boundary exists. Learn how maintainable Android applications are designed by building one.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp