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 collectToState helper that removes the usual init { flow.onEach { ... } } boilerplate
A launch helper 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 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/modelinterfaceOneTimeEvent
// ui/modelinterfaceOneTimeEvent
// ui/modelinterfaceOneTimeEvent
The exception handler uses the template method pattern. The base class always tracks the exception, and subclasses decide what, if anything, the user sees:
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
abstractclassBaseViewModel<VS>(
initialUiState: VS,
privatevalexceptionHandler: BaseUiExceptionHandler? = null,
) : ViewModel() {
privateval_state = MutableStateFlow(initialUiState)
/** The UI state. Composables collect this. */valstateFlow: StateFlow<VS> = _state.asStateFlow()
/** The current state, for reading inside the ViewModel. */valstate: VSget() = _state.valueprotectedfunsetState(update: (VS) -> VS) {
_state.update(update)
}
privatevaleventChannel = Channel<OneTimeEvent>(Channel.BUFFERED)
/** One-time events. Should have a single collector. */valeventsFlow: Flow<OneTimeEvent> = eventChannel.receiveAsFlow()
protectedfunsendEvent(event: OneTimeEvent) {
viewModelScope.launch { eventChannel.send(event) }
}
/** Collects this flow and folds each value into the UI state. */protectedfun <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. */protectedfunlaunch(
onError: (Throwable) -> Unit = ::onUnhandledError,
block: suspendCoroutineScope.() -> Unit,
): Job = viewModelScope.launch {
try {
block()
} catch (e: CancellationException) {
throwe
} catch (t: Throwable) {
onError(t)
}
}
protectedopenfunonUnhandledError(t: Throwable) {
valhandler = exceptionHandler ?: returnhandler.handleException(t)?.let(::sendEvent
abstractclassBaseViewModel<VS>(
initialUiState: VS,
privatevalexceptionHandler: BaseUiExceptionHandler? = null,
) : ViewModel() {
privateval_state = MutableStateFlow(initialUiState)
/** The UI state. Composables collect this. */valstateFlow: StateFlow<VS> = _state.asStateFlow()
/** The current state, for reading inside the ViewModel. */valstate: VSget() = _state.valueprotectedfunsetState(update: (VS) -> VS) {
_state.update(update)
}
privatevaleventChannel = Channel<OneTimeEvent>(Channel.BUFFERED)
/** One-time events. Should have a single collector. */valeventsFlow: Flow<OneTimeEvent> = eventChannel.receiveAsFlow()
protectedfunsendEvent(event: OneTimeEvent) {
viewModelScope.launch { eventChannel.send(event) }
}
/** Collects this flow and folds each value into the UI state. */protectedfun <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. */protectedfunlaunch(
onError: (Throwable) -> Unit = ::onUnhandledError,
block: suspendCoroutineScope.() -> Unit,
): Job = viewModelScope.launch {
try {
block()
} catch (e: CancellationException) {
throwe
} catch (t: Throwable) {
onError(t)
}
}
protectedopenfunonUnhandledError(t: Throwable) {
valhandler = exceptionHandler ?: returnhandler.handleException(t)?.let(::sendEvent
abstractclassBaseViewModel<VS>(
initialUiState: VS,
privatevalexceptionHandler: BaseUiExceptionHandler? = null,
) : ViewModel() {
privateval_state = MutableStateFlow(initialUiState)
/** The UI state. Composables collect this. */valstateFlow: StateFlow<VS> = _state.asStateFlow()
/** The current state, for reading inside the ViewModel. */valstate: VSget() = _state.valueprotectedfunsetState(update: (VS) -> VS) {
_state.update(update)
}
privatevaleventChannel = Channel<OneTimeEvent>(Channel.BUFFERED)
/** One-time events. Should have a single collector. */valeventsFlow: Flow<OneTimeEvent> = eventChannel.receiveAsFlow()
protectedfunsendEvent(event: OneTimeEvent) {
viewModelScope.launch { eventChannel.send(event) }
}
/** Collects this flow and folds each value into the UI state. */protectedfun <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. */protectedfunlaunch(
onError: (Throwable) -> Unit = ::onUnhandledError,
block: suspendCoroutineScope.() -> Unit,
): Job = viewModelScope.launch {
try {
block()
} catch (e: CancellationException) {
throwe
} catch (t: Throwable) {
onError(t)
}
}
protectedopenfunonUnhandledError(t: Throwable) {
valhandler = exceptionHandler ?: returnhandler.handleException(t)?.let(::sendEvent
4. The three bugs
Bug 1: launch swallowed cancellation
The first version caught Throwable:
} catch (t: Throwable) {
onError(t
} catch (t: Throwable) {
onError(t
} 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:
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:
funonChangeTheme(mode: ThemeMode) = launch {
settingsRepository.update { it.copy(themeMode = mode) } // no try/catch here
funonChangeTheme(mode: ThemeMode) = launch {
settingsRepository.update { it.copy(themeMode = mode) } // no try/catch here
funonChangeTheme(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:
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
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
setState { it.copy(loading = true) }
if (state.loading) { ... } // true or false, depending on where you called from
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:
Bug 3: collectToState ignored the lambda's parameter
// beforeonEach { value -> setState { state.reduce(value
// beforeonEach { value -> setState { state.reduce(value
// beforeonEach { 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:
// afteronEach { value -> setState { it.reduce(value
// afteronEach { value -> setState { it.reduce(value
// afteronEach { 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 ExceptionHandler
classSettingsUiExceptionHandler : BaseUiExceptionHandler() {
overridefunevaluateException(e: Throwable): OneTimeEvent? {
returnnull// TODO: Handle specific exceptions here
classSettingsUiExceptionHandler : BaseUiExceptionHandler() {
overridefunevaluateException(e: Throwable): OneTimeEvent? {
returnnull// TODO: Handle specific exceptions here
classSettingsUiExceptionHandler : BaseUiExceptionHandler() {
overridefunevaluateException(e: Throwable): OneTimeEvent? {
returnnull// TODO: Handle specific exceptions here
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.
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:
Channel for events, not SharedFlow or 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.
launch shadows the name on purpose. Inside a ViewModel, launch { } always means "with error handling". The cost is that the name hides CoroutineScope.launch in some scopes, so use viewModelScope.launch explicitly 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.