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 PerformLogout : ProfileUiEvent {
3 override val dispatchPolicy = UiEventDispatchPolicy.ThrottleFirst()
4 }
5}

Guidelines:

  • Model intent, not UI mechanics.
  • Start minimal and grow as the feature evolves.
  • Override dispatchPolicy only when the event needs non-default dispatch behavior.
  • Use ThrottleFirst for one-shot tap actions and DebounceLatest for latest-value effects like search.
  • For controlled inputs such as text fields, keep the visible input state immediate and debounce only the delayed side effect.

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 val sections: ImmutableList<Section>,
4 override val eventSink: (ProfileUiEvent) -> Unit,
5) : UiState<ProfileUiEvent>

Guidelines:

  • Annotate with @Immutable to reduce recompositions.
  • Keep state explicit.
  • If the state grows, split it into smaller nested data classes.
  • Pass the stable eventSink through UiState instead of recreating event lambdas in state().

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@ViewModelKey
2@ContributesIntoMap(AppScope::class, binding<ViewModel>())
3class ProfileViewModel : ViewModel(), Mvvm<ProfileUiEvent, ProfileUiState> {
4
5 private val eventSink = createEventSink(::handleEvent)
6 private val sectionsFlow = mutableState(persistentListOf<Section>()) { createSections() }
7
8 @Composable
9 override fun state(): ProfileUiState {
10 val currentSections by sectionsFlow.collectAsStateWithLifecycle()
11 return ProfileUiState(
12 sections = currentSections,
13 eventSink = eventSink,
14 )
15 }
16
17 private fun handleEvent(event: ProfileUiEvent) {
18 when (event) {
19 ProfileUiEvent.PerformLogout -> handleLogout()
20 }
21 }
22
23 private fun handleLogout() {
24 /* domain coordination */
25 }
26
27 private suspend fun createSections(): ImmutableList<Section> {
28 /* data preparation */
29 }
30}

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.

When to use assisted injection

The default path is a ViewModel contributed with @ViewModelKey and @ContributesIntoMap(AppScope::class, binding<ViewModel>()), created with metroViewModel(). These contributions do not need an additional @Inject annotation or an explicit class argument to @ViewModelKey. The contribution belongs to the app graph; the ViewModel instance belongs to its navigation entry's ViewModel store.

Use @AssistedInject when the ViewModel needs runtime arguments such as a route parameter or a value supplied by the UI graph. Register its @AssistedFactory with @ContributesIntoMap(AppScope::class) and @ManualViewModelAssistedFactoryKey, then call assistedMetroViewModel() inside the navigation entry. See PlaygroundViewModel and PlaygroundUiModule for the complete pattern.


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.PerformLogout)
9 },
10 )
11}

💡 State provided by the ViewModel may change frequently. By extracting eventSink outside callbacks you keep the wiring stable 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 3 entry provider.

The UiModule:

  • Contributes navigation entries.
  • Wires ViewModel factories.
  • Keeps navigation setup out of UI code.
kotlin
1import io.baselines.sample.ui.navigation.AppNavRoutes
2import io.baselines.toolkit.navigation.NavEntryFactory
3
4@ContributesTo(UiScope::class)
5interface ProfileUiModule {
6
7 @Provides
8 @IntoSet
9 fun provideProfileNavEntryFactory(): NavEntryFactory = {
10 entry<AppNavRoutes.Profile> {
11 ProfileRoute(metroViewModel())
12 }
13 entry<AppNavRoutes.EditProfile> {
14 EditProfileRoute(metroViewModel())
15 }
16 }
17}

Guidelines:

  • Use unique provide... function names.
  • Use metroViewModel() for the normal case where the ViewModel is created entirely from graph-provided dependencies.
  • Use assistedMetroViewModel() only when the ViewModel needs runtime args from navigation or route parameters.

Define Profile and EditProfile as @Serializable destinations implementing NavRoute in ui/navigation/AppNavRoutes.kt. NavModule combines the contributed NavEntryFactory set into a UI-scoped NavEntryProvider, which ComposeApp passes to NavDisplay.

Feature ViewModels inject io.baselines.toolkit.navigation.Navigator for commands such as navigate(AppNavRoutes.Profile) and navigateBack(). See the Navigation Guide for stack transformations, runtime arguments, startup, and restoration.


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.