messageCross Icon
Cross Icon
Mobile App Development

Scalable UI Architecture in Jetpack Compose: Structuring UI State, UI Events, & ViewModels

Scalable UI Architecture in Jetpack Compose: Structuring UI State, UI Events, & ViewModels
Scalable UI Architecture in Jetpack Compose: Structuring UI State, UI Events, & ViewModels

Jetpack Compose has redefined how UI is built in Android by embracing a declarative rendering model. Instead of manually updating views, the UI now reacts automatically to changes in state. This shift dramatically simplifies UI code for small and medium screens.

However, when complexity increases - multiple screens, feature modules, coordinated flows, offline caching, and dynamic UI interaction - Compose apps can become difficult to maintain if the underlying state architecture is not deliberate.

Common scaling pain points include:

  • UI State scattered across many composables → hard to reason about.
  • ViewModels doing too much, becoming “God objects”.
  • Multiple mutable states cause unpredictable recompositions.
  • Navigation logic is leaking into UI elements.
  • Business logic accidentally embedded inside composables.
  • Unit tests are failing or missing because UI and logic are coupled.

The solution: explicit state modeling, uni-directional data flow, and separation of UI interaction, business logic, and data sources.

Core Architectural Principles

Concept Role What it Should Contain What it Should Not Contain
UI State Immutable state container for the screen Loaded data, loading flags, errors Business logic, references to repository, context
UI Events User/system triggers for state change Button clicks, text changes, retry actions Navigation logic, feature logic
ViewModel Pure state reducer and event processor Mapping events → use cases → state UI rendering details, composables
Repository / Use Cases Business logic + data fetching Remote + local sync, transformations UI-specific conditions

This keeps everything testable, predictable, and stable even as screens grow.

Why Immutable UI State is Critical

Compose re-renders the UI whenever the state value changes. If your state is:

  • Implicit (stored across multiple variables)
  • Mutable (updated in place)
  • Spread across composables

Then recompositions become unpredictable, leading to flicker, redundant redraws, and performance issues.

Stable, Immutable State Example:

Code

data class ArticleUiState(
    val isLoading: Boolean = false,
    val articles: List<Article> = emptyList(),
    val errorMessage: String? = null,
    val isEmpty: Boolean = false
)

One screen → one state object. Always.

UI Events: Typed Action Channels

UI events should be explicit and centralized, not passed as callbacks everywhere.

Code

sealed interface ArticleUiEvent {
    object OnRefresh : ArticleUiEvent
    data class OnArticleClick(val id: Int) : ArticleUiEvent
    data class OnSearchQueryChange(val query: String) : ArticleUiEvent
}

This supports:

  • Tracing UI behavior from event → logic → state
  • Clean testing (when(event) is assertable)
  • Clear code navigation

ViewModel: The Deterministic State Machine

The ViewModel observes data sources, processes events, updates state, and never directly touches UI.

Code

@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val repository: ArticleRepository
) : ViewModel() {

Key patterns to use:

  • StateFlow or SnapshotState → Prefer StateFlow for multi-collector safety.
  • uiState.update { … } → Always update immutably.
  • onEvent(event) → Central event gateway.

Handling Navigation Without Breaking Architecture

Avoid calling NavController inside the ViewModel. Instead, expose navigation intents:

Code

private val _navigation = MutableSharedFlow()
val navigation = _navigation.asSharedFlow()

private fun openDetail(id: Int) {
    viewModelScope.launch { _navigation.emit(NavigationEvent.OpenDetail(id)) }
}

UI layer collects it:

Code

LaunchedEffect(Unit) {
    viewModel.navigation.collect { event ->
        when(event) {
            is NavigationEvent.OpenDetail -> onNavigate(event.id)
        }
    }
}

This avoids tightly coupling UI and navigation logic.

Multi-Module Scaling Strategy

For large applications, each feature should be isolated:

Code

app/
  core/
    ui/
    network/
    database/
  feature-articles/
    ui/
      ArticleScreen.kt
    domain/
      ArticleUseCases.kt
    data/
      ArticleRepository.kt

Advantages:

  • Faster builds with Gradle configuration caching
  • Feature teams work independently
  • Clear ownership boundaries

Optimizing Recomposition Performance

Techniques:

Use val state by viewModel.uiState.collectAsState()
Prefer derivedStateOf when computing display-only values
Avoid passing large object graphs - prefer IDs
Make lists stable using key = { item.id }

Avoid:

Storing state inside list item composablesUsing remember { mutableStateOf() } for business stateCreating objects inside @Composable frequently

Testing Strategy

ViewModel Test (No UI Required)

Code

@Test
fun refreshShowsLoading() = runTest {
    fakeRepo.shouldReturnError = false
    viewModel.onEvent(ArticleUiEvent.OnRefresh)
    assert(viewModel.uiState.value.isLoading)
}

UI Tests (Optional, Pure Visual)

  • Compose Test Rule
  • Assertions on rendered nodes
  • Snapshot UI tests for design consistency
Hire Now!

Hire Mobile Developers Today!

Ready to build a high-quality mobile app? Start your project with Zignuts' expert mobile developers today.

**Hire now**Hire Now**Hire Now**Hire now**Hire now

Compose Previews Done Right

Provide a fake ViewModel + sample state:

Code

@Preview(showBackground = true)
@Composable
fun PreviewArticleScreen() {
    ArticleScreen(
        onNavigate = {},
        viewModel = FakeArticleViewModel(
            ArticleUiState(
                articles = listOf(Article(1, "Test", "Content"))
            )
        )
    )
}

Previews allow UI review without running the app.

Moving Beyond Repositories: Why Use Cases Matter

In small apps, ViewModels often call repositories directly.
In larger apps, this leads to tightly coupled logic and duplication.

Use Cases provide a clean boundary for business logic:

Code

class RefreshArticlesUseCase @Inject constructor(
    private val repository: ArticleRepository
) {
    suspend operator fun invoke() = repository.refresh()
}

Benefits:

  • Reusable business operations across multiple screens
  • Keeps ViewModel thin
  • Easy to mock in tests
  • Encourages single responsibility

Updated ViewModel:

Code

class ArticleViewModel @Inject constructor(
    private val observeArticles: ObserveArticlesUseCase,
    private val refreshArticles: RefreshArticlesUseCase
) : ViewModel() {

Handling UI Side Effects Safely

Not all events lead to state changes. Some trigger side-effects:

  • Navigation
  • Showing Snackbars
  • Logging/analytics
  • Haptic feedback/system UI changes

These should not be stored in UI state.

Model them as one-time effects:

Code

private val _uiEffect = MutableSharedFlow<ArticleUiEffect>()
val uiEffect = _uiEffect.asSharedFlow()

sealed interface ArticleUiEffect {
    data class ShowSnackbar(val message: String) : ArticleUiEffect
}

Emit:

Code

viewModelScope.launch {
    _uiEffect.emit(ArticleUiEffect.ShowSnackbar("Failed to refresh!"))
}

Collect in UI:

Code

LaunchedEffect(Unit) {
    viewModel.uiEffect.collect { effect ->
        when(effect) {
            is ArticleUiEffect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message)
        }
    }
}

This avoids the classic snackbar shown multiple times problem.

Preserving State Through Process Death

Real production apps must handle OS process kill.
Jetpack ViewModel provides SavedStateHandle.

Use it for UI state that should survive app restoration, e.g., search text.

Code

class SearchViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    var query = savedStateHandle.getStateFlow("query", "")
        private set

    fun onQueryChange(newQuery: String) {
        savedStateHandle["query"] = newQuery
    }
}

This makes the state survive:

  • Rotation
  • Background + kill + restore
  • App switching

Avoiding Recomposition Storms With Text Input

Wrong (causes recomposition on every character):

Code

TextField(value = query, onValueChange = { viewModel.query = it })

Better: store only finalized intent, not raw characters

Code

TextField(value = localQuery, onValueChange = { localQuery = it })
LaunchedEffect(localQuery) {
    snapshotFlow { localQuery }
        .debounce(400)
        .collect { viewModel.onEvent(SearchUiEvent.OnSearchQueryChange(it)) }
}

This reduces:

  • Network load
  • Recomposition load
  • CPU time

Pagination & Infinite Scroll Pattern (Compose-Friendly)

Let UI emit the LoadMore event when we reach the end of the list:

Code

@Composable
fun ArticleList(
    articles: List<Article>,
    onLoadMore: () -> Unit
) {
    LazyColumn {
        itemsIndexed(articles) { index, item ->
            if (index == articles.lastIndex) onLoadMore()
            ArticleItem(item)
        }
    }
}

ViewModel handles the logic using batching:

Code

fun loadMore() = viewModelScope.launch {
    if (uiState.value.isLoadingNext) return@launch
    _uiState.update { it.copy(isLoadingNext = true) }
    val next = repository.loadNextPage()
    _uiState.update { it.copy(articles = it.articles + next, isLoadingNext = false) }
}

Keep pagination logic out of UI.

Real-Time Data Streams (WebSockets, Observables, BLE, etc.)

If your feature needs continuous updates:

Code

repository.liveArticleStream()
    .onEach { updates -> 
        _uiState.update { it.copy(articles = updates) } 
    }
    .launchIn(viewModelScope)

Always isolate streaming logic in Use Cases

Code

class ObserveArticlesUseCase @Inject constructor(
    private val repo: ArticleRepository
) {
    operator fun invoke(): Flow<List<Article>> = repo.articles
}

This makes real-time handling scalable and testable.

Complete Architectural Flow Diagram (Conceptual)

Code

UI (Compose)
   ↓ events
ViewModel (State Machine + Event Reducer)
   ↓ use cases
Domain Layer (Business Logic)
   ↓ repositories
Data Sources (Network + DB + Cache)
   ↓ data flows back up
ViewModel updates StateFlow → UI recomposes

Data travels down, events travel up. (Main concept of Jetpack Compose)
Always one direction.

Ready to implement a scalable Jetpack Compose architecture for your large Android app? Hire mobile developers from Zignuts, specializing in Kotlin, robust UI solutions, and scalable apps with full Agile support, NDAs, and daily updates. Contact us now for a free consultation!

Final Thoughts

As your application grows, consistency across screens becomes essential. Teams often benefit from establishing a UI contract template that every new screen follows: define state, events, effects, ViewModel interactions, and preview scenarios before writing visible UI code. This creates strong mental patterns and reduces onboarding complexity. Additionally, adopting shared UI components (buttons, loading layouts, empty states, list cells) inside a design system module ensures visual coherence and reduces redundancy. Finally, documentation matters - lightweight architecture guidelines, code samples, and feature folder templates prevent regressions and keep development velocity high as more contributors join the project.

The architecture we defined enables:

Requirement Result
Predictability UI only changes when the state changes
Testability ViewModels tested without Compose
Performance Less unnecessary recompositions
Stability Survives configuration + process death
Scalability Feature modules with well-defined boundaries
card user img
Twitter iconLinked icon

A technology enthusiast focused on crafting intuitive and high-performing mobile solutions that enhance everyday experiences.

card user img
Twitter iconLinked icon

Developer focused on creating user-friendly applications and improving system performance. Committed to continuous learning and helping others through technical writing.

Frequently Asked Questions

How do I verify a remote MongoDB developer’s skills?
What are the common challenges in hiring remote MongoDB developers?
How does Zignuts ensure quality in remote developer hiring?
Is MongoDB certification important for hiring?
What tools help manage remote MongoDB teams effectively?
Book Your Free Consultation Click Icon

Book a FREE Consultation

No strings attached, just valuable insights for your project

Valid number
Please complete the reCAPTCHA verification.
Claim My Spot!
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
download ready
Thank You
Your submission has been received.
We will be in touch and contact you soon!
View All Blogs