CMP

•

Three bugs I found in my own BaseViewModel

Three bugs I found in my own BaseViewModel

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/model
interface OneTimeEvent
// ui/model
interface OneTimeEvent
// 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

// 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

// 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

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

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

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

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

} 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:

} catch (e: CancellationException) {
    throw e
} catch (t: Throwable) {
    onError(t

} catch (e: CancellationException) {
    throw e
} catch (t: Throwable) {
    onError(t

} 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

fun onChangeTheme(mode: ThemeMode) = launch {
    settingsRepository.update { it.copy(themeMode = mode) }   // no try/catch here

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

// 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

// 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

// before
protected fun setState(update: (VS) -> VS) {
    viewModelScope.launch {
        _state.emit(update(state

// 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
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:

// after
protected fun setState(update: (VS) -> VS) {
    _state.update(update

// after
protected fun setState(update: (VS) -> VS) {
    _state.update(update

// 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

// before
onEach { value -> setState { state.reduce(value

// 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

// after
onEach { value -> setState { it.reduce(value

// 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 ExceptionHandler

class SettingsUiExceptionHandler : BaseUiExceptionHandler() {
    override fun evaluateException(e: Throwable): OneTimeEvent? {
        return null // TODO: Handle specific exceptions here

class SettingsUiExceptionHandler : BaseUiExceptionHandler() {
    override fun evaluateException(e: Throwable): OneTimeEvent? {
        return null // TODO: Handle specific exceptions here

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

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

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

singleOf(::SettingsUiExceptionHandler)
viewModelOf(::SettingsViewModel

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

@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

@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

ObserveEvents(viewModel.eventsFlow) { event ->
    when (event) {
        is ShowError -> scope.launch { snackbarHostState.showSnackbar(event.kind.label

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

@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

@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 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.

Create a free website with Framer, the website builder loved by startups, designers and agencies.