ADR-0006: UI State Modeling Strategy
DateFebruary 8, 2026
CategoryUI & Navigation
Tagsstate-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.