The UI layer in Android serves two key purposes: it shows your app’s data on screen and handles user interactions. When users tap buttons or when data updates happen, the UI must reflect these changes immediately.

Transforming Data for Display

Raw data rarely fits what your UI needs. The UI layer transforms backend data into UI-ready formats. For example, a NewsUiState combines article content, bookmark status, and user messages into one package the UI can easily display.

How Unidirectional Data Flow Works

Modern Android apps use Unidirectional Data Flow (UDF) for state management. Here’s how it works:

  1. Your ViewModel creates and owns the UI state
  2. The UI observes this state through StateFlow or LiveData
  3. When users act, the UI sends events to the ViewModel
  4. The ViewModel updates the data and creates a new state
  5. The UI displays the new state

This clear, one-way flow prevents confusing state updates and bugs.

Why Immutable State Matters

Always use immutable data classes for UI state. This means:

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val newsItems: List<NewsItemUiState> = emptyList(),
    val userMessages: List<String> = emptyList()
)

Immutable states can’t be changed after creation. This gives you:

  • Consistent views based on complete state snapshots
  • A single source of truth
  • Easier debugging
  • Fewer unnecessary UI updates

Jetpack Compose Integration

Compose was built for immutable state. It thrives with UDF, automatically updating the UI when state changes. Here’s a simple example:

class NewsViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState = _uiState.asStateFlow()
    
    fun bookmarkArticle(articleId: String) {
        _uiState.update { currentState ->
            // Create a new state with the updated article
            val updatedItems = currentState.newsItems.map { item ->
                if (item.id == articleId) item.copy(bookmarked = true) else item
            }
            currentState.copy(newsItems = updatedItems)
        }
    }
}
 
@Composable
fun NewsScreen(viewModel: NewsViewModel = viewModel()) {
    // Convert StateFlow to Compose state
    val uiState by viewModel.uiState.collectAsState()
    
    LazyColumn {
        items(uiState.newsItems) { newsItem ->
            NewsItemCard(
                newsItem = newsItem,
                onBookmarkClick = { viewModel.bookmarkArticle(newsItem.id) }
            )
        }
    }
}

The UI automatically updates when state changes. No manual refresh needed.

State Hoisting

Lift state up to the highest component that needs it. This keeps child components simpler and more reusable. Let parent components manage state and pass down only what children need.

Testing Benefits

UDF makes testing easy. You can create state snapshots and verify your UI shows them correctly. No complex setup or mocking needed—just create state objects and test.

Error Handling

Always include error states in your UI model:

data class NewsUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val newsItems: List<NewsItemUiState> = emptyList()
)

This lets you show loading spinners or error messages naturally.

One-Time Events

Some actions don’t change state but need to happen once, like navigation or showing a toast. Use a Channel or Event wrapper:

sealed class NewsEvent {
    data class Navigate(val articleId: String) : NewsEvent()
    data class ShowToast(val message: String) : NewsEvent()
}

Saving State

Use SavedStateHandle in your ViewModel to preserve state across screen rotations and process death. Here’s how:

class NewsViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    // Define keys for values to save
    private companion object {
        const val UI_STATE_KEY = "ui_state_key"
        const val LAST_REFRESH_KEY = "last_refresh_key"
    }
    
    // Initialize state from saved state or default
    private val _uiState = MutableStateFlow(
        savedStateHandle.get<NewsUiState>(UI_STATE_KEY) ?: NewsUiState()
    )
    val uiState = _uiState.asStateFlow()
    
    init {
        // Save state automatically when it changes
        viewModelScope.launch {
            _uiState.collect { newState ->
                savedStateHandle[UI_STATE_KEY] = newState
            }
        }
    }
    
    // Save simple values directly
    fun setLastRefreshTime(timeMillis: Long) {
        savedStateHandle[LAST_REFRESH_KEY] = timeMillis
    }
}

This automatically preserves your state when the app goes into the background or during configuration changes.

Performance Tips

Be careful with large immutable states. Here’s how to optimize:

// PROBLEM: This will cause recomposition of the entire list when any field changes
class NewsViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState = _uiState.asStateFlow()
}
 
// SOLUTION: Split state into smaller, more focused flows
class NewsViewModel : ViewModel() {
    // Authentication state rarely changes
    private val _authState = MutableStateFlow(AuthState())
    val authState = _authState.asStateFlow()
    
    // News items might change frequently
    private val _newsItems = MutableStateFlow<List<NewsItemUiState>>(emptyList())
    val newsItems = _newsItems.asStateFlow()
    
    // Error states are transient
    private val _errorState = MutableStateFlow<ErrorState?>(null)
    val errorState = _errorState.asStateFlow()
        .distinctUntilChanged() // Prevent duplicate emissions
}

This approach lets you update just what changed without rebuilding everything. Use distinctUntilChanged() to filter out duplicate states.

Real-World Implementation

This approach fits perfectly with MVVM or MVI patterns. In MVVM, the ViewModel owns state. In MVI, the Intent processor creates new states based on user actions.

Remember the Lifecycle

ViewModels outlive configuration changes but die with their associated UI components. Use appropriate scopes for your ViewModels to avoid memory leaks.

By following these principles, you’ll build Android apps that are easier to understand, test, and maintain.