TackleBox is a personal Android app for managing fishing gear: what’s in the box, which lures have been producing, what was bought two seasons ago and still hasn’t been rigged. Off-the-shelf inventory apps don’t map to how a fisherman thinks about gear, so I built one. A field tool has a different reliability bar than something you demo once. If TackleBox shows wrong state on the water — spinner running over a loaded list, data that looks missing but isn’t — I stop trusting it. A tool I don’t trust doesn’t come out of the bag. The whole project was only useful if it was reliable.
The spinner appeared over a fully loaded list. I added isLoading = false, pushed, moved on. Two weeks later it was back. A second patch, then a third — three separate call sites, each fix technically correct, none touching the actual bug.
That pattern — valid logic, impossible UI — is the signal that the wrong thing is being fixed.
The impossible states were always possible
Three independent variables — isLoading, hasError, items — can be combined in eight distinct ways. Business logic intended exactly three of them. The other five were invalid states I had created by choosing that shape of data. A spinner over a loaded list isn’t a Compose rendering issue — Compose faithfully renders whatever you hand it. The bug was that “loading while displaying items simultaneously” was a legal state in my own type system. Every patch at a call site was a bandage over a structural hole.
Collapsing all three into a single object changes what states can exist:
data class TackleUiState(
val isLoading: Boolean = false,
val items: List<TackleItem> = emptyList(),
val error: String? = null
)
But the data class alone isn’t sufficient. The critical discipline is replacing the entire object atomically rather than updating individual fields. The ViewModel’s collect handler:
.collect { items -> _uiState.update { TackleUiState(items = items) } }
This constructs a fresh object where isLoading defaults to false. Contrast with .copy(items = items) — that leaves isLoading at whatever value it currently holds. The former makes “loading + items populated” structurally impossible. The latter just makes it less likely. There’s a practical difference between guarding against invalid combinations and making them unrepresentable. The first approach scales with every new call site. The second is solved once.
One write path, enforced at the type level
class TackleViewModel(private val repo: TackleRepository) : ViewModel() {
private val _uiState = MutableStateFlow(TackleUiState())
val uiState: StateFlow<TackleUiState> = _uiState.asStateFlow()
init { loadItems() }
private fun loadItems() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
repo.getItems()
.catch { e -> _uiState.update { it.copy(isLoading = false, error = e.message) } }
.collect { items -> _uiState.update { TackleUiState(items = items) } }
}
}
}
_uiState is private because a ViewModel with external write access isn’t a single source of truth — it’s a shared mutable variable with a nicer name. I skipped this constraint early in the project and spent real time chasing update-ordering bugs: two concurrent updates interleaving to produce state neither intended. Locking down the write path eliminated that class of bug entirely. The public API exposes read-only StateFlow<TackleUiState>; every mutation routes through the ViewModel’s own methods. That constraint is enforced by the type, not by convention that a caller might not remember.
The Composable should have nothing to say
@Composable
fun TackleScreen(viewModel: TackleViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
when {
uiState.isLoading -> CircularProgressIndicator()
uiState.error != null -> ErrorMessage(uiState.error!!)
else -> TackleList(uiState.items)
}
}
Boring on purpose. A Composable that makes decisions is doing two jobs, and the second job eventually leaks state logic into the presentation layer in ways that are painful to test and harder to trace. This one renders. That’s the full contract. (The pending upgrade to collectAsStateWithLifecycle() is a one-line swap once the lifecycle dependency is wired in — not a refactor.)
Not every operation needs a pipeline
UiEvent sealed class + Channel has legitimate uses: navigation triggers, Snackbars, one-off side effects that shouldn’t replay on resubscription. For ordinary mutations the overhead adds nothing:
fun deleteItem(id: String) {
viewModelScope.launch { repo.delete(id) }
}
Indirection where none is needed looks principled until you’re reading it six months later with no memory of the problem it was meant to solve.
Where Room earns the architecture’s weight
Room’s DAO returns Flow<List<Item>> that emits automatically on every database write:
repo.observeItems()
.map { entities -> entities.map { it.toDomain() } }
.collect { items -> _uiState.update { it.copy(items = items, isLoading = false) } }
Before Room, every delete had to trigger an explicit reload. The question “did I remember to reload after this mutation?” was a permanent source of latent bugs. Now the UI follows the database directly. For the first month, the StateFlow architecture was overhead I paid on faith: stricter access control, atomic object replacement, extra type ceremony. The first time I deleted an item and the list updated without any explicit trigger, the overhead became obviously justified. Until an architecture does its job end-to-end, it’s just complexity. After that, it’s load-bearing.
Why LiveData lasted a week
I started with LiveData because the tutorials do. Left for specific reasons:
- StateFlow always carries an initial value — no
nullon first collection - Pure Kotlin: no Android framework import in the ViewModel, unit tests run without Robolectric scaffolding
- Coroutine operators compose naturally:
map,combine,filter - LiveData requires a lifecycle owner to observe; StateFlow doesn’t carry that coupling into the ViewModel layer
Migration was mostly a type swap. About an hour.
The spinner was showing me a state the architecture had always permitted. I’d been patching every place it appeared instead of eliminating the condition that made it possible.