All Decisions

ADR-0006: UI State Modeling Strategy

DateFebruary 8, 2026
CategoryUI & Navigation
Tags
state-managementcompose-ui

Context

As LocusFlow grows in scope we need a clear, consistent approach for representing and updating UI state across screens and flows. Compose encourages local state in Composables, but app-level features require a single source of truth for predictability, testability, and persistence.

Constraints and triggers:

  • Kotlin + Jetpack Compose UI layer
  • Hilt + ViewModel-centric architecture
  • Offline-first, local persistence focus
  • Need for deterministic UI behavior, reproducible state for testing and synthesis features

Decision

We will adopt a unidirectional UI state model where ViewModels expose immutable UI state objects via StateFlow and UI actions are sent as intents/events to ViewModels. One-off effects (navigation, toasts, dialogs) will be emitted via a separate, consumable stream (SharedFlow or Channel). Local, ephemeral UI-only state within a Composable is allowed for purely visual concerns (e.g., expanded/collapsed transient controls) but must not be the source of truth for domain data.

Scope boundaries:

  • Covers screen and feature-level state managed by ViewModels.
  • Does not mandate a specific naming scheme for state classes, but encourages sealed types and small immutable data classes.
  • Does not prescribe a full MVI framework; patterns may be lightweight and idiomatic to Kotlin/Compose.

Rationale

  • Predictability: Immutable state exposed by StateFlow makes UI rendering deterministic and easier to test.
  • Testability: ViewModels can be unit tested by asserting emitted state sequences.
  • Separation of concerns: Distinguishing persistent/domain state from ephemeral UI-only state reduces accidental data loss and inconsistency.
  • Interoperability: StateFlow and SharedFlow are well-supported in Kotlin coroutines and Compose.
  • Minimal runtime overhead compared with heavier MVI frameworks while preserving unidirectional flow benefits.

Implementation notes

  • ViewModel API:
    • Expose a single val uiState: StateFlow<ScreenState> per screen/feature.
    • Expose an fun accept(intent: UiIntent) or fun send(event: UiEvent) for UI actions.
    • Expose val effects: SharedFlow<UiEffect> (replay = 0) for one-off side effects.
  • State modeling:
    • Prefer sealed interfaces / sealed classes for distinct screen states (Loading / Content / Empty / Error) when appropriate.
    • Keep state objects immutable and map domain models to lightweight UI models in ViewModels.
  • Ephemeral UI state:
    • Use remember / rememberSaveable for purely view-local pieces (animations, transient focus, scroll positions when appropriate).
    • Document when a piece of state is intentionally local vs part of uiState.

Consequences

  • Positive:
    • Easier to reason about and test UI behavior.
    • Cleaner separation between domain persistence and UI rendering.
    • Simplifies serialization for caching or synthesis tasks.
  • Downsides:
    • Slight increase in boilerplate (state classes, intent wrappers).
    • Requires discipline to avoid leaking domain mutation into ephemeral UI state.

Alternatives Considered

  • Full MVI framework (e.g., Redux-like store) — rejected for added complexity and boilerplate for an app of this scale.
  • Relying heavily on Compose local state — rejected due to difficulties in testing and cross-screen consistency.

Notes

  • Revisit if composition complexity grows or if a cross-cutting state store becomes advantageous for performance or feature needs.