Rethinking UI Architecture in the Age of Jetpack Compose

Why Jetpack Compose Is More Than “A New UI Toolkit”

Jetpack Compose isn’t merely a replacement for XML and ViewGroups—it’s a paradigm shift from imperative to declarative UI programming. In the old days, we’d manipulate Views imperatively: findViewById, setText, addOnClickListener, and so on. This led to brittle code where UI state was scattered across lifecycle methods, adapters, and callbacks.

Compose, built on Kotlin and leveraging coroutines, turns UI into a pure function of state. It handles recomposition automatically when state changes, offloading the “how” of updates to the runtime. But why does this matter for architecture? Because it decouples UI description from mutation logic, enabling better separation of concerns and testability.

Consider the productivity gains: No more wrestling with RecyclerView adapters or fragment transactions. Instead, we focus on data flows and composability. However, this shift exposes flaws in traditional architectures like MVVM, which were designed around mutable Views. As seniors, we’ve optimized for those patterns, but Compose demands we rethink state management at a deeper level.

In production apps I’ve led, adopting Compose reduced boilerplate by 40% and bugs related to UI synchronization by half. Yet, the real win is architectural: It encourages unidirectional data flow and immutable state, aligning Android closer to web frameworks like React.

Declarative UI: What Changes Fundamentally

Declarative UI means describing what the UI should look like based on current state, not how to mutate it step-by-step. This is Compose’s core philosophy, and it fundamentally alters how we model apps.

UI as a Function of State

In Compose, every Composable is a function that takes state as input and emits UI as output. When state changes, the runtime recomposes only the affected parts. This is pure functional programming applied to UI.

Let’s illustrate with a complete example: A simple counter app. In traditional Views, you’d have a Button with an OnClickListener that increments a variable and updates a TextView. In Compose, it’s declarative:

Kotlin

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }  // State hoisted here for simplicity

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

Here, CounterScreen is a function of count. When count changes, Compose recomposes the Text but skips unchanged parts. As seniors, note the use of remember and mutableStateOf—these ensure state persists across recompositions without leaking.

This model scales: Complex UIs become trees of Composables, each reacting to its slice of state. No more manual invalidation or diffing; the runtime handles it efficiently via slot tables and recomposition scopes.

Eliminating the “Hidden UI State”

Traditional Android hid state in Views (e.g., EditText’s text, RecyclerView’s scroll position). This “hidden state” led to bugs like state loss on rotation or fragment recreation. Compose eliminates this by making all state explicit and hoistable.

Hidden state often crept into architectures via side effects in lifecycle methods. In Compose, state is owned externally (e.g., in ViewModels) and passed down, preventing surprises.

Extend the counter example to handle configuration changes properly with a ViewModel:

Kotlin

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

    fun increment() {
        viewModelScope.launch {
            _count.value++
        }
    }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel) {
    val count by viewModel.count.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { viewModel.increment() }) {
            Text("Increment")
        }
    }
}

Now, state is owned by the ViewModel, surviving recompositions and config changes. No hidden state—everything is explicit. This forces better architecture: State flows down, events flow up.

How Compose Forces a Rethink of Traditional Android Architecture

Traditional patterns like MVC, MVP, and MVVM were built for imperative UIs. Compose’s declarative nature breaks their assumptions.

MVC / MVP / MVVM under Declarative UI

In MVC, the Controller mutates the View based on Model changes. MVP adds a Presenter for testability. MVVM uses data binding for two-way sync.

In Compose, there’s no mutable View to bind to—UI is recomposed from state. So, MVVM adapts well: ViewModel holds state, Composables observe it.

But it’s not seamless. Data binding’s two-way nature conflicts with declarative one-way flow. Instead, use Flows or State for observation.

Where These Patterns Break Down

MVC/MVP rely on direct View manipulation, which Compose avoids. Even MVVM breaks when we over-rely on mutable state inside Composables, leading to recomposition storms.

A common breakdown: In View-based MVVM, you’d bind a list to a RecyclerView adapter. In Compose, naive lists cause full recompositions if not keyed properly.

Example of a broken pattern: A list screen with MVVM but poor state handling:

Kotlin

// Bad: Mutable list inside Composable causes unnecessary recompositions
@Composable
fun UserList(users: List<User>) {  // Assume users is from ViewModel
    LazyColumn {
        items(users) { user ->
            Text(user.name)
        }
    }
}

If users changes frequently, the entire list recomposes. Fix by keying:

Kotlin

@Composable
fun UserList(users: List<User>) {
    LazyColumn {
        items(users, key = { it.id }) { user ->
            Text(user.name)
        }
    }
}

Patterns break when we don’t hoist state or ignore recomposition optimization. Compose forces us to think in terms of state derivation, not mutation.

State Ownership as the New Architectural Center

In Compose, architecture revolves around who owns the state. Hoist state to the highest necessary level: Local for ephemerals (e.g., animations), ViewModel for screen-level, Repository for app-wide.

This “state hoisting” enables reusability. A Composable shouldn’t manage its own state if it’s meant to be stateless.

Full example: A form with validation. State owned in ViewModel:

Kotlin

data class FormState(val name: String = "", val email: String = "", val isValid: Boolean = false)

class FormViewModel : ViewModel() {
    private val _formState = MutableStateFlow(FormState())
    val formState = _formState.asStateFlow()

    fun updateName(name: String) {
        _formState.update { it.copy(name = name, isValid = validate(it.copy(name = name))) }
    }

    fun updateEmail(email: String) {
        _formState.update { it.copy(email = email, isValid = validate(it.copy(email = email))) }
    }

    private fun validate(state: FormState): Boolean = state.name.isNotBlank() && state.email.contains("@")
}

@Composable
fun FormScreen(viewModel: FormViewModel) {
    val state by viewModel.formState.collectAsState()

    Column {
        TextField(value = state.name, onValueChange = viewModel::updateName, label = { Text("Name") })
        TextField(value = state.email, onValueChange = viewModel::updateEmail, label = { Text("Email") })
        Button(onClick = { /* Submit */ }, enabled = state.isValid) {
            Text("Submit")
        }
    }
}

State ownership centralizes logic, making testing ViewModels straightforward (no UI dependencies).

One-Way Data Flow: Theory vs Android Reality

Theory: Data flows down from sources to UI; events flow up to mutate sources. No cycles.

In Android reality, lifecycles, configuration changes, and background threads complicate this. Coroutines and Flows help, but pitfalls like multiple collectors or unhandled errors persist.

Theory assumes pure functions; Android has side effects (e.g., navigation). Use Navigation Compose with state-aware routes.

Example: One-way flow in a login screen:

Kotlin

sealed class LoginEvent {
    data class EmailChanged(val email: String) : LoginEvent()
    data class PasswordChanged(val password: String) : LoginEvent()
    object Submit : LoginEvent()
}

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState = _uiState.asStateFlow()

    fun onEvent(event: LoginEvent) {
        when (event) {
            is LoginEvent.EmailChanged -> _uiState.update { it.copy(email = event.email) }
            is LoginEvent.PasswordChanged -> _uiState.update { it.copy(password = event.password) }
            LoginEvent.Submit -> viewModelScope.launch { /* API call */ }
        }
    }
}

data class LoginUiState(val email: String = "", val password: String = "", val loading: Boolean = false)

@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val state by viewModel.uiState.collectAsState()

    Column {
        TextField(value = state.email, onValueChange = { viewModel.onEvent(LoginEvent.EmailChanged(it)) })
        TextField(value = state.password, onValueChange = { viewModel.onEvent(LoginEvent.PasswordChanged(it)) })
        Button(onClick = { viewModel.onEvent(LoginEvent.Submit) }) { Text("Login") }
    }
}

Reality check: Handle errors with a separate effect flow (e.g., SharedFlow for toasts).

Comparing Compose Architecture with React / SwiftUI

Compose draws from React (recomposition like re-renders) and SwiftUI (declarative syntax). React’s hooks mirror Compose’s remember; both emphasize unidirectional flow.

Differences: React has virtual DOM; Compose uses runtime slot tables for efficiency. SwiftUI integrates deeply with Combine (like Flows).

In React, state with useState/useReducer; in Compose, mutableStateOf/StateFlow. Common: Lift state up.

Trap: React’s memoization vs Compose’s stability annotations (@Stable).

Example cross-over: A React-like reducer in Compose ViewModel.

Common Mental Model Traps for Senior View-Based Developers

Trap 1: Treating Composables like Views—expecting lifecycle methods. Solution: Use effects (LaunchedEffect) for side effects.

Trap 2: Over-mutating state inside Composables. Hoist!

Trap 3: Ignoring keys in lists, causing performance issues.

Trap 4: Mixing imperative code (e.g., manual focus) with declarative.

Full code for avoiding trap: Use derivedStateOf for computed values.

Kotlin

@Composable
fun DerivedExample(items: List<Item>) {
    val filteredItems by remember(items) { derivedStateOf { items.filter { it.active } } }
    LazyColumn {
        items(filteredItems) { /* ... */ }
    }
}

Designing for Change, Not Screens

Traditional design: Screen-by-screen. Compose: Design for data changes triggering UI updates.

Focus on state transitions, not static layouts. Use previews for rapid iteration.

Example: A dynamic dashboard reacting to real-time data.

Key Architectural Principles for the Rest of the Series

  1. Hoist state aggressively.
  2. Use unidirectional flow.
  3. Optimize recompositions with stability.
  4. Test state logic independently.
  5. Embrace composition over inheritance.

Leave a Reply

Your email address will not be published. Required fields are marked *