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:
UiEvent— user intentUiState— single source of truth for renderingViewModel— UI logic and state producerScreen— pure UIRoute— wiring layerUiModule— 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.
kotlin1sealed interface ProfileUiEvent : UiEvent {2 data object OnLogoutClicked : ProfileUiEvent3}
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.
kotlin1@Immutable2data class ProfileUiState(3 override val eventSink: (ProfileUiEvent) -> Unit,4) : UiState<ProfileUiEvent>
Guidelines:
- Annotate with
@Immutableto 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.
kotlin1@Inject2@ContributesIntoMap(3 scope = AppScope::class,4 binding = binding<ViewModel>()5)6@ViewModelKey(ProfileViewModel::class)7class ProfileViewModel : BaselineViewModel<ProfileUiEvent, ProfileUiState>() {89 private val sectionsFlow =10 mutableState(persistentListOf()) { createSections() }1112 @Composable13 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 }2324 private fun handleLogout() {25 /* domain coordination */26 }2728 private suspend fun createSections(): ImmutableList<Section> {29 /* data preparation */30 }31}
Why state() is composable
state() is marked @Composable so it can:
- Participate in Compose snapshots.
- Automatically recompose when state changes.
- 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.
kotlin1@Composable2fun 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.
kotlin1@Composable2fun ProfileRoute(viewModel: ProfileViewModel) {3 val state = viewModel.state()4 val eventSink = state.eventSink5 ProfileScreen(6 sections = state.sections,7 onLogoutClicked = {8 eventSink(ProfileUiEvent.OnLogoutClicked)9 },10 )11}
💡 State provided by the ViewModel may change frequently. By extracting
eventSinkoutside 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.
kotlin1@ContributesTo(UiScope::class)2interface ProfileUiModule {34 @Provides5 @IntoSet6 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.