A small UDF base class for Compose Multiplatform: one state, one-time events and central error handling
Every Compose Multiplatform project I've seen ends up with some kind of BaseViewModel. Mine holds a single immutable UI state, sends one-time events like snackbars and navigation, and routes unexpected errors to one place. It's about 100 lines, and a review pass found three problems in my first version.
This post shows the class, the three bugs and why they matter, and how it's used. It also shows a few tests, because this class sits under every screen.
What you'll get
- A base ViewModel with one state, one event stream and one error path
- A
collectToStatehelper that removes the usualinit { flow.onEach { ... } }boilerplate - A
launchhelper that handles errors without breaking coroutine cancellation - An exception handler you can swap out, with a hook for analytics
- Tests for the parts most likely to break
Full source: GitHub. This is the BaseViewModel that the dark mode post mentions but doesn't explain.
1. The design
Three ideas, all from unidirectional data flow:
-
State is one immutable object per screen, exposed as a
StateFlow. The UI renders it and never changes it. - Events are things that should happen once: show a snackbar, navigate, open a dialog. They don't belong in state, because state is replayed to every new collector. A snackbar that reappears after rotation is a bug.
- Errors from background work go through one handler that decides what the user sees.
The dependencies are kotlinx-coroutines and androidx.lifecycle (ViewModel), both of which support multiplatform.
2. The events and the handler
The event type is a marker interface, so each feature defines its own events:
// ui/model
interface OneTimeEvent
The exception handler uses the template method pattern. The base class always tracks the exception, and subclasses decide what, if anything, the user sees:
// ui/error
abstract class BaseUiExceptionHandler {
fun handleException(e: Throwable): OneTimeEvent? {
trackException(e)
return evaluateException(e)
}
protected abstract fun evaluateException(e: Throwable): OneTimeEvent?
protected fun trackException(e: Throwable) {
// TODO: send to analytics / crash reporting
}
}
Returning null means "log it, but show nothing". That's useful for errors the user can't act on.
A concrete handler (the exception types here are examples, so use your own):
enum class ErrorKind { Network, Unknown }
data class ShowError(val kind: ErrorKind) : OneTimeEvent
class AppUiExceptionHandler : BaseUiExceptionHandler() {
override fun evaluateException(e: Throwable): OneTimeEvent? = when (e) {
is NoConnectionException -> ShowError(ErrorKind.Network)
else -> ShowError(ErrorKind.Unknown)
}
}
The event carries an enum, not a message string. Turning it into localized text is a UI job, the same reasoning as the ThemeMode.label() extension in the previous post.
3. The class
abstract class BaseViewModel<VS>(
initialUiState: VS,
private val exceptionHandler: BaseUiExceptionHandler? = null,
) : ViewModel() {
private val _state = MutableStateFlow(initialUiState)
/** The UI state. Composables collect this. */
val stateFlow: StateFlow<VS> = _state.asStateFlow()
/** The current state, for reading inside the ViewModel. */
val state: VS get() = _state.value
protected fun setState(update: (VS) -> VS) {
_state.update(update)
}
private val eventChannel = Channel<OneTimeEvent>(Channel.BUFFERED)
/** One-time events. Should have a single collector. */
val eventsFlow: Flow<OneTimeEvent> = eventChannel.receiveAsFlow()
protected fun sendEvent(event: OneTimeEvent) {
viewModelScope.launch { eventChannel.send(event) }
}
/** Collects this flow and folds each value into the UI state. */
protected fun <T> Flow<T>.collectToState(reduce: VS.(T) -> VS): Job =
onEach { value -> setState { it.reduce(value) } }
.launchIn(viewModelScope)
/** Launches in viewModelScope. Failures go to [onError], cancellation does not. */
protected fun launch(
onError: (Throwable) -> Unit = ::onUnhandledError,
block: suspend CoroutineScope.() -> Unit,
): Job = viewModelScope.launch {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
onError(t)
}
}
protected open fun onUnhandledError(t: Throwable) {
val handler = exceptionHandler ?: return
handler.handleException(t)?.let(::sendEvent)
}
}
4. The three bugs
Bug 1: launch swallowed cancellation
The first version caught Throwable:
} catch (t: Throwable) {
onError(t)
}
CancellationException is a Throwable. When the user leaves a screen, viewModelScope is cancelled and that exception is how the coroutine finds out. Catching it means the cancellation is reported as an error, and the coroutine doesn't end the way coroutines are supposed to. The fix is to rethrow it before the general catch:
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
onError(t)
}
This is the most common coroutine mistake in try/catch code, and it's easy to miss because it rarely crashes. It just causes odd behavior.
Do I need to catch CancellationException in every ViewModel?
No. The try/catch lives once, inside launch, so call sites stay clean:
fun onChangeTheme(mode: ThemeMode) = launch {
settingsRepository.update { it.copy(themeMode = mode) } // no try/catch here
}
Rethrowing doesn't crash the app either. CancellationException is how coroutines signal a normal cancellation, so when it propagates out of a launch, the coroutine ends quietly. Here's what happens in each case:
| What happens inside the block | Result |
|---|---|
| Normal completion | Nothing |
| The user leaves the screen | Rethrown, the coroutine ends silently |
| A real exception | Caught and sent to onUnhandledError, which can turn it into an event |
Without the helper, a real exception in a plain viewModelScope.launch would crash the app. The helper prevents that, and the rethrow just keeps a normal screen exit from being treated as a failure.
Where you do need to care is when you write your own try/catch or runCatching around suspend calls:
// Swallows cancellation too
try { repo.refresh() } catch (e: Exception) { showError() }
runCatching { repo.refresh() }
// Rethrow cancellation first
try { repo.refresh() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { showError() }
Timeouts are cancellations too. withTimeout throws a TimeoutCancellationException, which is a CancellationException, so inside launch a timeout ends the coroutine silently and shows no error. If you want to react to a timeout, use withTimeoutOrNull and handle the null.
Bug 2: setState launched a coroutine for no reason
// before
protected fun setState(update: (VS) -> VS) {
viewModelScope.launch {
_state.emit(update(state))
}
}
This worked, but only by accident. viewModelScope runs on Dispatchers.Main.immediate, and emit on a MutableStateFlow never suspends, so the update ran immediately when called from the main thread. Called from a background dispatcher, it was deferred to Main instead. So this:
setState { it.copy(loading = true) }
if (state.loading) { ... } // true or false, depending on where you called from
behaved differently depending on the caller's thread. MutableStateFlow.update is synchronous, thread-safe, and needs no coroutine:
// after
protected fun setState(update: (VS) -> VS) {
_state.update(update)
}
Bug 3: collectToState ignored the lambda's parameter
// before
onEach { value -> setState { state.reduce(value) } }
Inside the lambda, state is the ViewModel's property, not the state the lambda receives. With the old setState the two were always the same, so it was harmless. With update it isn't, because MutableStateFlow.update retries the lambda if another thread changes the state in between, and reading the property inside it bypasses that. Use the parameter:
// after
onEach { value -> setState { it.reduce(value) } }
The reduce lambda has the state as its receiver (VS.(T) -> VS), so call sites can write copy(...) directly.
The small ones
Two properties used getters (get() = _state.asStateFlow()), which create a new wrapper on every access. That's wasteful, and it matters for events: a composable that uses LaunchedEffect(viewModel.eventsFlow) would see a different flow on every recomposition and restart the effect. Plain property initializers fix both.
5. Using it
An exception handler
class SettingsUiExceptionHandler : BaseUiExceptionHandler() {
override fun evaluateException(e: Throwable): OneTimeEvent? {
return null // TODO: Handle specific exceptions here
}
}
A ViewModel
class SettingsViewModel(
private val settingsRepository: SettingsRepository,
settingsUiExceptionHandler: SettingsUiExceptionHandler
) : BaseViewModel<SettingsUiState>(
SettingsUiState(settingsRepository.settings.value.themeMode),
settingsUiExceptionHandler
) {
init {
settingsRepository.settings.collectToState { settingsItem ->
copy(themeMode = settingsItem.themeMode)
}
}
fun onChangeTheme(mode: ThemeMode) = launch {
settingsRepository.update { state -> state.copy(themeMode = mode) }
}
}
If the update throws, onUnhandledError runs and the handler gets a chance to react. This one returns null for now, so the error is tracked but nothing is shown. Returning an event (like the ShowError example in section 2) is what surfaces it in the UI. The ViewModel itself contains no error-handling code either way.
Wiring it up with Koin
singleOf(::SettingsUiExceptionHandler)
viewModelOf(::SettingsViewModel)
Collecting events in the UI
@Composable
fun <E> ObserveEvents(events: Flow<E>, onEvent: (E) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(events, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
events.collect(onEvent)
}
}
}
ObserveEvents(viewModel.eventsFlow) { event ->
when (event) {
is ShowError -> scope.launch { snackbarHostState.showSnackbar(event.kind.label()) }
}
}
The channel buffers events while nobody collects, and repeatOnLifecycle only collects while the screen is started. An error that happens while the app is in the background therefore waits until the user returns. (label() is the same UI-layer extension pattern as the theme labels.)
6. Test the part that breaks
This class sits under every screen, so a few tests are cheap insurance. viewModelScope needs a Main dispatcher in tests:
@OptIn(ExperimentalCoroutinesApi::class)
class BaseViewModelTest {
private class CounterViewModel(
handler: BaseUiExceptionHandler? = null,
) : BaseViewModel<Int>(0, handler) {
val reported = mutableListOf<Throwable>()
fun increment() = setState { it + 1 }
fun failWith(t: Throwable) = launch { throw t }
override fun onUnhandledError(t: Throwable) {
reported += t
super.onUnhandledError(t)
}
}
private object TestEvent : OneTimeEvent
@BeforeTest fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
@AfterTest fun tearDown() = Dispatchers.resetMain()
@Test
fun `concurrent setState calls do not lose updates`() = runTest {
val vm = CounterViewModel()
withContext(Dispatchers.Default) {
(1..1000).map { async { vm.increment() } }.awaitAll()
}
assertEquals(1000, vm.stateFlow.value)
}
@Test
fun `cancellation is not reported as an error`() = runTest {
val vm = CounterViewModel()
vm.failWith(CancellationException("screen closed"))
assertTrue(vm.reported.isEmpty())
}
@Test
fun `unhandled error becomes a one-time event`() = runTest {
val handler = object : BaseUiExceptionHandler() {
override fun evaluateException(e: Throwable) = TestEvent
}
val vm = CounterViewModel(handler)
vm.failWith(IllegalStateException("boom"))
assertEquals(TestEvent, vm.eventsFlow.first())
}
}
7. Trade-offs
-
Channel for events, not
SharedFlowor a field in the state. A channel delivers each event once and buffers while nobody listens. The cost is that it expects a single collector. State fields are replayed, so they suit "what to show" and not "what just happened". - A base class, not delegation. Inheritance keeps ViewModels short, but it's a coupling you'll feel if you ever need a different state model. For a project this size I think the trade is right, and I'd revisit it if the hierarchy grew.
- Not a full MVI framework. There's no reducer, intent type or middleware. Public functions on the ViewModel are the "intents", and that's enough until a screen proves otherwise.
-
launchshadows the name on purpose. Inside a ViewModel,launch { }always means "with error handling". The cost is that the name hidesCoroutineScope.launchin some scopes, so useviewModelScope.launchexplicitly if you need the raw one.
Wrapping up
The class itself is small, but it has real traps: cancellation handling, thread-dependent state updates, and a lambda parameter that's easy to ignore. If you copy one thing from this post, make it the catch (e: CancellationException) { throw e } line, and remember that it applies to your own try/catch and runCatching around suspend calls too.
Top comments (0)