Screen Structure and Wiring

This document explains how a feature screen is structured in Baselines and how its pieces are wired together. Each component has a single responsibility and a clear place in the flow from user interaction to UI rendering.

The goal is:

  • Predictable screen structure
  • Explicit data flow
  • Minimal recompositions
  • Easy testability and maintenance

Overview: How a Screen Is Composed

A feature screen is built from the following building blocks:

  1. UiEvent — user intent
  2. UiState — single source of truth for rendering
  3. ViewModel — UI logic and state producer
  4. Screen — pure UI
  5. Route — wiring layer
  6. UiModule — dependency injection and navigation registration

Each layer exists to separate concerns and keep the UI stable as the feature grows.


1. UiEvent — Capturing User Actions

Responsibility: Represent everything the user can do on the screen.

UiEvent is a sealed type that enumerates all user interactions such as clicks, gestures, or selections.

kotlin
1sealed interface ProfileUiEvent : UiEvent {
2 data object OnLogoutClicked : ProfileUiEvent
3}

Guidelines:

  • Model intent, not UI mechanics.
  • Start minimal and grow as the feature evolves.
  • Avoid passing lambdas directly through the UI.

2. UiState — Single Source of Truth

Responsibility: Hold all data required to render the screen.

The UI reads from UiState only. There is no implicit state hidden in the composables.

kotlin
1@Immutable
2data class ProfileUiState(
3 override val eventSink: (ProfileUiEvent) -> Unit,
4) : UiState<ProfileUiEvent>

Guidelines:

  • Annotate with @Immutable to reduce recompositions.
  • Keep state explicit.
  • If the state grows, split it into smaller nested data classes.

3. ViewModel — UI Logic and State Producer

Responsibility: Own UI logic and produce UiState.

The ViewModel:

  • Coordinates domain logic.
  • Transforms data into UI-ready state.
  • Exposes a single state() entry point.
kotlin
1@Inject
2@ContributesIntoMap(
3 scope = AppScope::class,
4 binding = binding<ViewModel>()
5)
6@ViewModelKey(ProfileViewModel::class)
7class ProfileViewModel : BaselineViewModel<ProfileUiEvent, ProfileUiState>() {
8
9 private val sectionsFlow =
10 mutableState(persistentListOf()) { createSections() }
11
12 @Composable
13 override fun state(): ProfileUiState {
14 val sections by sectionsFlow.collectAsStateWithLifecycle()
15 return ProfileUiState(
16 sections = sections,
17 ) { event ->
18 when (event) {
19 ProfileUiEvent.OnLogoutClicked -> handleLogout()
20 }
21 }
22 }
23
24 private fun handleLogout() {
25 /* domain coordination */
26 }
27
28 private suspend fun createSections(): ImmutableList<Section> {
29 /* data preparation */
30 }
31}

Why state() is composable

state() is marked @Composable so it can:

  1. Participate in Compose snapshots.
  2. Automatically recompose when state changes.
  3. Expose stable references to the UI.

4. Screen — Pure UI Layer

Responsibility: Render UI only.

The Screen:

  • Contains no logic.
  • Holds no state.
  • Forwards user interactions via callbacks.
kotlin
1@Composable
2fun ProfileScreen(
3 sections: ImmutableList<Section>,
4 onLogoutClicked: () -> Unit,
5) {
6 /* UI layout */
7}

Guidelines:

  • Keep screens stateless.
  • Never call ViewModel directly.
  • Treat callbacks as event emitters only.

5. Route — Wiring State to UI

Responsibility: Bind ViewModel state to the Screen.

The Route:

  • Pulls state from the ViewModel.
  • Extracts stable references.
  • Connects UI callbacks to UiEvents.
kotlin
1@Composable
2fun ProfileRoute(viewModel: ProfileViewModel) {
3 val state = viewModel.state()
4 val eventSink = state.eventSink
5 ProfileScreen(
6 sections = state.sections,
7 onLogoutClicked = {
8 eventSink(ProfileUiEvent.OnLogoutClicked)
9 },
10 )
11}

💡 State provided by the ViewModel may change frequently. By extracting eventSink outside callbacks/lambdas you make sure Compose treats them as stable objects and avoid redundant recompositions.

Why this layer exists

Separating the Route:

  • Keeps screens pure.
  • Prevents accidental recompositions.
  • Centralizes wiring logic.

6. UiModule — Dependency Injection and Navigation

Responsibility: Register the screen in the navigation graph.

The UiModule:

  • Contributes navigation entries.
  • Wires ViewModel factories.
  • Keeps navigation setup out of UI code.
kotlin
1@ContributesTo(UiScope::class)
2interface ProfileUiModule {
3
4 @Provides
5 @IntoSet
6 fun provideProfileNavGraphEntry(): NavGraphEntry = NavGraphEntry {
7 composable<AppNavRoutes.Profile> {
8 ProfileRoute(metroViewModel())
9 }
10 composable<AppNavRoutes.EditProfile> {
11 EditProfileRoute(metroViewModel())
12 }
13 }
14}

Guidelines:

  • Use unique provide… function names.
  • metroViewModel() will create the necessary instance automatically if the ViewModel has all the necessary class annotations.
  • Multiple routes can be registered in one module.

Mental Model

Think of a screen as a pipeline:

User Action → UiEvent → ViewModel → UiState → Screen

Each layer has:

  • One responsibility
  • One direction of data flow
  • No hidden coupling

Further Reference

For a more advanced example that:

  • Combines multiple flows
  • Reflects loading and error states
  • Demonstrates complex state coordination

See PlaygroundViewModel.