Skip to content

marcin-adamczewski/kmp-mvi

Repository files navigation

kmp-mvi

A lightweight, flexible, and powerful MVI (Model-View-Intent) library for Kotlin Multiplatform. Designed to simplify state management in your KMP projects with first-class support for Coroutines, Flow, and Compose.

Features

  • Multiplatform: Works on Android, iOS, JVM, Wasm, and Linux.
  • Effects: Robust handling of one-time events like navigation or toast messages.
  • Powerful UI actions handling: Handle UI actions with power of Flow and Coroutines.
  • ViewModel: Optional integration with androidx.lifecycle.ViewModel.
  • Progress Management: Easy-to-use API for tracking loading states across multiple operations.
  • Error Management: Centralized error handling and propagation to the UI.
  • Logging: Built-in support for logging state transitions, actions, and effects.
  • Lifecycle support: Observe lifecycle events and react accordingly.
  • Compose Support: Dedicated extensions for state collection and effect handling in Jetpack Compose.
  • Test utils: Helper functions for testing your MVI components with Turbine.

Installation

Add the following to your build.gradle.kts in your KMP project:

repositories {
    mavenCentral()
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            // Core MVI components - if ViewModel integration is not required
            implementation("io.github.marcin-adamczewski:core:[libVersion]")
            
            // Core components + ViewModel integration
            implementation("io.github.marcin-adamczewski:viewmodel:[libVersion]")
            
            // Compose Multiplatform extensions
            implementation("io.github.marcin-adamczewski:compose:[libVersion]")
        }

        commonTest.dependencies {
            // Test utils
            implementation("io.github.marcin-adamczewski:test:[libVersion]")
        }
    }
}

Usage

1. Define your MVI Components

Define your state, actions, and effects. They should implement MviState, MviAction, and MviEffect respectively.

// State of your UI.
data class SongsState(
    val isLoading: Boolean = false,
    val error: UiError? = null,
    val songs: List<Song> = emptyList(),
) : MviState

// Actions that can be dispatched from the UI.
sealed interface SongsAction : MviAction {
    data class SearchQueryChanged(val query: String) : SongsAction
    data class SongSelected(val song: Song) : SongsAction
}

// Effects that are emitted from the MviContainer and observed by the UI.
// Usually those are navigation events, toast messages, etc.
sealed interface SongsEffect : MviEffect {
    data class OpenSongDetails(val songId: String) : SongsEffect
    data class OpenMediaPlayer(val songId: String) : SongsEffect
}

2. Create your MviComponent

Extend MviViewModel or MviStateManager and implement handleActions(). Alternatively, you can use the Kotlin DSL for a more concise configuration.

Using Inheritance

class SongsViewModel(
    private val repository: MusicRepository,
    private val errorManager: ErrorManager,
) : MviViewModel<SongsAction, SongsState, SongsEffect>(
    initialState = SongsState()
) {

    init {
        // onInit is called once, when the first subscriber connects to the state or effects.
        onInit { 
            // withProgress - Shows progress at the beginning of the block and hides it when completed 
            withProgress {
                repository.fetchSongs()
                    // setState - Updates state based on the current state
                    .onSuccess { setState { copy(songs = it, error = null) } }
            }
        }
    }

    override fun ActionsManager<SongsAction>.handleActions() {
        onAction<SongSelected> {
            setEffect { OpenSongDetails(it.song.id) }
        }

        onActionFlow<SearchQueryChanged> {
            debounce(300).map {
                setState { copy(query = it) }
            }
        }
    }
}

Using Kotlin DSL

You can use the mvi or mviViewModel DSL to configure your component without inheriting from MVI base classes and overriding handleActions. You have to implement MviContainerHost interface so UI can access the component's state and effects.

class SongsViewModel(
    private val repository: MusicRepository
) : ViewModel, MviContainerHost<SongsAction, SongsState, SongsEffect> {

    override val component = mviViewModel<SongsAction, SongsState, SongsEffect>(SongsState()) {
        onInit {
            // withProgress - Shows progress at the beginning of the block and hides it when completed
            withProgress {
                repository.fetchSongs()
                    // setState - Updates state based on the current state
                    .onSuccess {
                        setState { copy(songs = it, error = null) }
                    }
            }
        }

        actions {
            onAction<SongSelected> { action ->
                setEffect { OpenSongDetails(action.song.id) }
            }

            onActionFlow<SearchQueryChanged> {
                debounce(300).map { 
                    setState { copy(query = it) }
                }
            }
        }
    }
}

Note: The library features a built-in lifecycle management system based on the number of active state and effects subscribers. You can react to lifecycle events using onInit, onSubscribe, and onUnsubscribe callbacks. The lifecycle of the MVI component is automatically managed. E.g. when using collectAsStateWithLifecycle() or effects.consume {} in Compose, it will trigger onInit once, onSubscribe when the screen enters the foreground, and onUnsubscribe when it leaves, allowing for efficient resource management.

3. Use in Compose

Connect your UI with the ViewModel using the provided extensions.

@Composable
fun SongsScreen(viewModel: SongsViewModel) {
    // Collect state with lifecycle awareness
    val state by viewModel.collectAsStateWithLifecycle()
    var searchQuery by rememberSavable { mutableStateOf("") }

    // Handle one-time events
    viewModel.ConsumeEffects { effect ->
        when (effect) {
            is SongsEffect.OpenSongDetails -> { /* navigate to details */ }
        }
    }

    Column {
        TextField(
            value = searchQuery,
            onValueChange = { 
                searchQuery = it
                // Send search query to the ViewModel
                viewModel.submitAction(SongsAction.SearchQueryChanged(it)) 
            }
        )
        
        state.songs.forEach { song ->
            SongItem(
                text = song.title,
                onClick = {
                    // Send song click event to the ViewModel
                    viewModel.submitAction(SongsAction.SongSelected(song)) 
                }
            )
        }
    }
}

You can pass viewmodel::submitAction function down the hierarchy to your child components. That way you don't have to pass many event functions down the hierarchy.

Logging

Built-in support for logging to track all actions, state changes, effects, and lifecycle events in your console. This is extremely helpful for debugging complex state transitions and verifying behavior in both code and tests. You can also send logs to a remote service, like Crashlytics so it's much easier to understand why something crashed.

Example log output:

SongsViewModel@021ba2c6: [Initial State] - SongsState(isLoading=true, error=null, songs=null)
SongsViewModel@021ba2c6: [Lifecycle] - onInit
SongsViewModel@021ba2c6: [Lifecycle] - onSubscribe
SongsViewModel@021ba2c6: [State] - SongsState(isLoading=false, error=null, songs=[Song(id=1, title=Midnight City, artistDisplayName=M83, releaseDate=2025-12-18)])
SongsViewModel@021ba2c6: [Action] - SearchQueryChanged(query=Water)
SongsViewModel@021ba2c6: [Action] - SongSelected(song=Song(id=13, title=Watermelon Sugar, artistDisplayName=Harry Styles, releaseDate=2025-12-18))
SongsViewModel@021ba2c6: [Effect] - OpenSongDetails(songId=13)
SongsViewModel@021ba2c6: [Lifecycle] - onUnsubscribe

Loggers can be configured via MviConfig.

Progress Tracking

The library provides a built-in ProgressManager to track loading states easily.

// In your ViewModel
init {
    observeProgress { isLoading ->
        setState { copy(isLoading = isLoading) }
    }
}

// In handleActions
onAction<AddToFavoritesClicked> {
    // Automatically manage loading state during the block
    withProgress {
        val songs = repository.getSongs()
        setState { copy(songs = songs) }
    }
}

// Or using Flow transformers
onActionFlow<Init> {
    repository.getSongsFlow()
        .watchProgress() // Shows loading on start and hides when first value is received or Flow is completed
        .onSuccess { songs ->
            setState { copy(songs = songs) }
        }
}

Testing

The library provides a dedicated test module with helper functions for testing your MVI components. It's built on top of Turbine and kotlinx-coroutines-test.

State testing

Use testState to verify state transitions in your ViewModel or StateManager. It provides a StateManagerFlowTurbine which allows you to submit actions and await for state changes.

@Test
fun `when initialized then fetched all songs`() = runTest {
    mviComponent.testState(this) {
        assertEquals(listOf(song1), expectMostRecentItem().songs)
    }
}

@Test
fun `when query changed then search songs`() = runTest {
    mviComponent.testState(this) {
        submitAction(SongsAction.SearchQueryChanged("Song 1"))
        assertEquals(expectedSongs, expectMostRecentItem().songs)
    }
}

Effects testing

Use testEffects to verify that your component emits the expected effects.

@Test
fun `when song clicked then open song details`() = runTest {
    mviComponent.testEffects(this) {
        submitAction(SongsAction.SongSelected(song))
        assertEquals(SongsEffect.OpenSongDetails(song.id), awaitItem())
    }
}

Simplified Test Cases

The test module includes several higher-level test utilities for common scenarios.

whenActionThenEffect

A concise way to assert that a specific action triggers a specific effect.

@Test
fun `when song clicked then open song details`() = runTest {
    whenActionThenEffect(
        stateComponent = mviComponent,
        actionToSubmit = SongsAction.SongSelected(song),
        expectedEffect = SongsEffect.OpenSongDetails(song.id),
    )
}

whenActionThenShowProgress

Verifies that an action triggers a loading state that is automatically hidden when the operation completes. It uses TEST_DELAY constant (2ms by default) to verify progress state. For this to work, ensure your repository mock/fake has a corresponding delay.

@Test
fun `when searched then show and hide loading`() = runTest {
    // repository mock should have a delay equal to TEST_DELAY
    whenActionThenShowProgress(
        stateComponent = viewModel,
        actionToSubmit = SongsAction.SearchQueryChanged("Song 1"),
        stateFieldToAssert = { it.isLoading }
    )
}

Other available helpers include:

  • whenInitThenShowError
  • whenActionThenShowError
  • whenTextChangedThenUpdateState
  • testItemToggled

For more examples, check out SongsDslViewModelTest.kt.

License

Copyright 2025 Marcin Adamczewski

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages