
Implements the Model-View-Intent pattern for managing complex UI states with predictability, testability, and maintainability. Offers explicit state modeling, unidirectional data flow, and handles asynchronous operations effectively.
A Kotlin Multiplatform library implementing the Model-View-Intent (MVI) pattern for predictable, testable, and maintainable UI state management.
This library provides a complete MVI architecture with opinionated implementations designed to handle complex presentation logic while maintaining 100% unit testability.
The Model-View-Intent pattern offers several advantages for modern application development:
Screens written with this library tend to be verbose. This is a deliberate trade-off. The verbosity comes from:
However, this verbosity makes sense when you optimize for:
If your screens are simple forms with minimal logic, this pattern may be overkill. But for complex user flows (multi-step wizards, real-time updates, intricate validation), the structure pays dividends.
User Action → Intent → Reducer → New State → View Update
↓
Async Command → executeAsyncCommand() → Async Intent → Reducer → ...
The library enforces a strict unidirectional data flow:
(State, Intent) -> State that computes state transitionsThe library distinguishes between:
This separation provides different backpressure strategies:
Commands represent one-time effects that shouldn't be modeled as persistent state:
Commands are automatically cleared after emission to prevent re-execution on configuration changes.
Reducers must be pure: same inputs always produce the same output. This makes:
The Async<T> type makes "loading" states explicit:
sealed interface Async<out T> {
data object Determining : Async<Nothing>
value class Determined<T>(val value: T) : Async<T>
}This eliminates ambiguous null values and makes async operations visible in the type system.
The library can detect and prevent multiple subscriptions to state flows, catching UI binding bugs during development:
multiSubscriptionBehaviour = MultiSubscriptionBehaviour.ThrowErrorThis library is not yet published to a public repository. Recommended consumption methods:
git submodule add https://github.com/yourorg/mvi.git libs/mviThen in settings.gradle.kts:
includeBuild("libs/mvi")Clone the repository locally and reference it:
settings.gradle.kts:
includeBuild("../path/to/mvi")Then in your module's build.gradle.kts:
dependencies {
implementation("au.lovecraft:mvi")
}// Complete internal state
data class LoginState(
val username: String = "",
val password: String = "",
val loginResult: Async<Boolean> = Async.Determining,
override val commands: List<LoginCommand> = emptyList()
) : MviState<LoginState, LoginCommand> {
override fun byClearingCommands() = copy(commands = emptyList())
}
// Intents represent all possible events
sealed interface LoginIntent
sealed interface LoginUserIntent : LoginIntent {
data class UsernameChanged(val value: String) : LoginUserIntent
data class PasswordChanged(val value: String) : LoginUserIntent
data object LoginButtonClicked : LoginUserIntent
}
sealed interface LoginAsyncIntent : LoginIntent {
data class LoginCompleted(val success: Boolean) : LoginAsyncIntent
}
// Commands for one-time effects
sealed interface LoginCommand
sealed interface LoginAsyncCommand : LoginCommand {
data class AuthenticateUser(val username: String, val password: String) : LoginAsyncCommand
}
sealed interface LoginViewCommand : LoginCommand {
data object NavigateToHome : LoginViewCommand
data object ShowInvalidCredentialsError : LoginViewCommand
}fun loginReducer(state: LoginState, intent: LoginIntent): LoginState = when (intent) {
is LoginUserIntent.UsernameChanged -> state.copy(username = intent.value)
is LoginUserIntent.PasswordChanged -> state.copy(password = intent.value)
is LoginUserIntent.LoginButtonClicked -> state.copy(
loginResult = Async.Determining,
commands = listOf(LoginAsyncCommand.AuthenticateUser(state.username, state.password))
)
is LoginAsyncIntent.LoginCompleted -> state.copy(
loginResult = Async.Determined(intent.success),
commands = if (intent.success) {
listOf(LoginViewCommand.NavigateToHome)
} else {
listOf(LoginViewCommand.ShowInvalidCredentialsError)
}
)
}class LoginViewModel(
private val authRepository: AuthRepository,
scopes: Scopes
) : BaseMviViewModel<
State = LoginState,
ViewState = LoginViewState,
Command = LoginCommand,
AsyncCommand = LoginAsyncCommand,
ViewCommand = LoginViewCommand,
Intent = LoginIntent,
AsyncIntent = LoginAsyncIntent,
UserIntent = LoginUserIntent
>(
reducer = ::loginReducer,
viewStateMapper = { state -> LoginViewState(
username = state.username,
password = state.password,
isLoading = state.loginResult is Async.Determining,
canSubmit = state.username.isNotBlank() && state.password.isNotBlank()
) },
asyncCommandClass = LoginAsyncCommand::class,
viewCommandClass = LoginViewCommand::class,
multiSubscriptionBehaviour = MultiSubscriptionBehaviour.LogError,
scopes = scopes
) {
override fun initialState() = LoginState()
override fun executeAsyncCommand(asyncCommand: LoginAsyncCommand) {
when (asyncCommand) {
is LoginAsyncCommand.AuthenticateUser -> {
viewModelScopes.io.launch {
val success = authRepository.authenticate(
asyncCommand.username,
asyncCommand.password
)
onAsyncIntent(LoginAsyncIntent.LoginCompleted(success))
}
}
}
}
// Public API for the view layer
fun onUsernameChanged(username: String) =
onUserIntent(LoginUserIntent.UsernameChanged(username))
fun onPasswordChanged(password: String) =
onUserIntent(LoginUserIntent.PasswordChanged(password))
fun onLoginClicked() =
onUserIntent(LoginUserIntent.LoginButtonClicked)
}// Android Compose example
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val viewState by viewModel.viewStateFlow.collectAsState()
LaunchedEffect(Unit) {
viewModel.viewCommandFlow.collect { command ->
when (command) {
LoginViewCommand.NavigateToHome -> navController.navigate("home")
LoginViewCommand.ShowInvalidCredentialsError -> {
snackbarHost.showSnackbar("Invalid credentials")
}
}
}
}
Column {
TextField(
value = viewState.username,
onValueChange = viewModel::onUsernameChanged,
label = { Text("Username") }
)
TextField(
value = viewState.password,
onValueChange = viewModel::onPasswordChanged,
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation()
)
Button(
onClick = viewModel::onLoginClicked,
enabled = viewState.canSubmit && !viewState.isLoading
) {
if (viewState.isLoading) {
CircularProgressIndicator()
} else {
Text("Login")
}
}
}
}class LoginReducerTest {
@Test
fun `username change updates state`() {
val state = LoginState()
val newState = loginReducer(state, LoginUserIntent.UsernameChanged("alice"))
assertEquals("alice", newState.username)
assertEquals(emptyList(), newState.commands)
}
@Test
fun `login button click triggers auth command`() {
val state = LoginState(username = "alice", password = "secret")
val newState = loginReducer(state, LoginUserIntent.LoginButtonClicked)
assertEquals(Async.Determining, newState.loginResult)
assertEquals(
listOf(LoginAsyncCommand.AuthenticateUser("alice", "secret")),
newState.commands
)
}
@Test
fun `successful login navigates to home`() {
val state = LoginState(loginResult = Async.Determining)
val newState = loginReducer(state, LoginAsyncIntent.LoginCompleted(success = true))
assertEquals(Async.Determined(true), newState.loginResult)
assertEquals(listOf(LoginViewCommand.NavigateToHome), newState.commands)
}
@Test
fun `failed login shows error`() {
val state = LoginState(loginResult = Async.Determining)
val newState = loginReducer(state, LoginAsyncIntent.LoginCompleted(success = false))
assertEquals(Async.Determined(false), newState.loginResult)
assertEquals(listOf(LoginViewCommand.ShowInvalidCredentialsError), newState.commands)
}
}The Async<T> type provides explicit modeling of asynchronous operations:
data class ProfileState(
val userData: Async<User> = Async.Determining,
// ...
) : MviState<ProfileState, ProfileCommand>
// In your UI
when (viewState.userData) {
Async.Determining -> LoadingIndicator()
is Async.Determined -> UserProfile(viewState.userData.value)
}The Scopes helper bundles coroutine dispatchers for consistent concurrency management:
val scopes = Scopes(
mainDispatcher = Dispatchers.Main,
logicDispatcher = Dispatchers.Default,
ioDispatcher = Dispatchers.IO
)
// In ViewModel
viewModelScopes.io.launch { /* I/O work */ }
viewModelScopes.logic.launch { /* CPU work */ }User and async intents have different backpressure strategies:
This ensures the UI remains responsive under load while internal operations maintain correctness.
Commands are automatically cleared between state emissions, but repeated commands are supported via a heartbeat mechanism. This ensures navigation commands always fire, even if they're identical to previous commands.
Configured for Kotlin Multiplatform:
Requirements:
./gradlew build
./gradlew testThis software is released under the LGPL License. See LICENSE.md for details.
A Kotlin Multiplatform library implementing the Model-View-Intent (MVI) pattern for predictable, testable, and maintainable UI state management.
This library provides a complete MVI architecture with opinionated implementations designed to handle complex presentation logic while maintaining 100% unit testability.
The Model-View-Intent pattern offers several advantages for modern application development:
Screens written with this library tend to be verbose. This is a deliberate trade-off. The verbosity comes from:
However, this verbosity makes sense when you optimize for:
If your screens are simple forms with minimal logic, this pattern may be overkill. But for complex user flows (multi-step wizards, real-time updates, intricate validation), the structure pays dividends.
User Action → Intent → Reducer → New State → View Update
↓
Async Command → executeAsyncCommand() → Async Intent → Reducer → ...
The library enforces a strict unidirectional data flow:
(State, Intent) -> State that computes state transitionsThe library distinguishes between:
This separation provides different backpressure strategies:
Commands represent one-time effects that shouldn't be modeled as persistent state:
Commands are automatically cleared after emission to prevent re-execution on configuration changes.
Reducers must be pure: same inputs always produce the same output. This makes:
The Async<T> type makes "loading" states explicit:
sealed interface Async<out T> {
data object Determining : Async<Nothing>
value class Determined<T>(val value: T) : Async<T>
}This eliminates ambiguous null values and makes async operations visible in the type system.
The library can detect and prevent multiple subscriptions to state flows, catching UI binding bugs during development:
multiSubscriptionBehaviour = MultiSubscriptionBehaviour.ThrowErrorThis library is not yet published to a public repository. Recommended consumption methods:
git submodule add https://github.com/yourorg/mvi.git libs/mviThen in settings.gradle.kts:
includeBuild("libs/mvi")Clone the repository locally and reference it:
settings.gradle.kts:
includeBuild("../path/to/mvi")Then in your module's build.gradle.kts:
dependencies {
implementation("au.lovecraft:mvi")
}// Complete internal state
data class LoginState(
val username: String = "",
val password: String = "",
val loginResult: Async<Boolean> = Async.Determining,
override val commands: List<LoginCommand> = emptyList()
) : MviState<LoginState, LoginCommand> {
override fun byClearingCommands() = copy(commands = emptyList())
}
// Intents represent all possible events
sealed interface LoginIntent
sealed interface LoginUserIntent : LoginIntent {
data class UsernameChanged(val value: String) : LoginUserIntent
data class PasswordChanged(val value: String) : LoginUserIntent
data object LoginButtonClicked : LoginUserIntent
}
sealed interface LoginAsyncIntent : LoginIntent {
data class LoginCompleted(val success: Boolean) : LoginAsyncIntent
}
// Commands for one-time effects
sealed interface LoginCommand
sealed interface LoginAsyncCommand : LoginCommand {
data class AuthenticateUser(val username: String, val password: String) : LoginAsyncCommand
}
sealed interface LoginViewCommand : LoginCommand {
data object NavigateToHome : LoginViewCommand
data object ShowInvalidCredentialsError : LoginViewCommand
}fun loginReducer(state: LoginState, intent: LoginIntent): LoginState = when (intent) {
is LoginUserIntent.UsernameChanged -> state.copy(username = intent.value)
is LoginUserIntent.PasswordChanged -> state.copy(password = intent.value)
is LoginUserIntent.LoginButtonClicked -> state.copy(
loginResult = Async.Determining,
commands = listOf(LoginAsyncCommand.AuthenticateUser(state.username, state.password))
)
is LoginAsyncIntent.LoginCompleted -> state.copy(
loginResult = Async.Determined(intent.success),
commands = if (intent.success) {
listOf(LoginViewCommand.NavigateToHome)
} else {
listOf(LoginViewCommand.ShowInvalidCredentialsError)
}
)
}class LoginViewModel(
private val authRepository: AuthRepository,
scopes: Scopes
) : BaseMviViewModel<
State = LoginState,
ViewState = LoginViewState,
Command = LoginCommand,
AsyncCommand = LoginAsyncCommand,
ViewCommand = LoginViewCommand,
Intent = LoginIntent,
AsyncIntent = LoginAsyncIntent,
UserIntent = LoginUserIntent
>(
reducer = ::loginReducer,
viewStateMapper = { state -> LoginViewState(
username = state.username,
password = state.password,
isLoading = state.loginResult is Async.Determining,
canSubmit = state.username.isNotBlank() && state.password.isNotBlank()
) },
asyncCommandClass = LoginAsyncCommand::class,
viewCommandClass = LoginViewCommand::class,
multiSubscriptionBehaviour = MultiSubscriptionBehaviour.LogError,
scopes = scopes
) {
override fun initialState() = LoginState()
override fun executeAsyncCommand(asyncCommand: LoginAsyncCommand) {
when (asyncCommand) {
is LoginAsyncCommand.AuthenticateUser -> {
viewModelScopes.io.launch {
val success = authRepository.authenticate(
asyncCommand.username,
asyncCommand.password
)
onAsyncIntent(LoginAsyncIntent.LoginCompleted(success))
}
}
}
}
// Public API for the view layer
fun onUsernameChanged(username: String) =
onUserIntent(LoginUserIntent.UsernameChanged(username))
fun onPasswordChanged(password: String) =
onUserIntent(LoginUserIntent.PasswordChanged(password))
fun onLoginClicked() =
onUserIntent(LoginUserIntent.LoginButtonClicked)
}// Android Compose example
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val viewState by viewModel.viewStateFlow.collectAsState()
LaunchedEffect(Unit) {
viewModel.viewCommandFlow.collect { command ->
when (command) {
LoginViewCommand.NavigateToHome -> navController.navigate("home")
LoginViewCommand.ShowInvalidCredentialsError -> {
snackbarHost.showSnackbar("Invalid credentials")
}
}
}
}
Column {
TextField(
value = viewState.username,
onValueChange = viewModel::onUsernameChanged,
label = { Text("Username") }
)
TextField(
value = viewState.password,
onValueChange = viewModel::onPasswordChanged,
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation()
)
Button(
onClick = viewModel::onLoginClicked,
enabled = viewState.canSubmit && !viewState.isLoading
) {
if (viewState.isLoading) {
CircularProgressIndicator()
} else {
Text("Login")
}
}
}
}class LoginReducerTest {
@Test
fun `username change updates state`() {
val state = LoginState()
val newState = loginReducer(state, LoginUserIntent.UsernameChanged("alice"))
assertEquals("alice", newState.username)
assertEquals(emptyList(), newState.commands)
}
@Test
fun `login button click triggers auth command`() {
val state = LoginState(username = "alice", password = "secret")
val newState = loginReducer(state, LoginUserIntent.LoginButtonClicked)
assertEquals(Async.Determining, newState.loginResult)
assertEquals(
listOf(LoginAsyncCommand.AuthenticateUser("alice", "secret")),
newState.commands
)
}
@Test
fun `successful login navigates to home`() {
val state = LoginState(loginResult = Async.Determining)
val newState = loginReducer(state, LoginAsyncIntent.LoginCompleted(success = true))
assertEquals(Async.Determined(true), newState.loginResult)
assertEquals(listOf(LoginViewCommand.NavigateToHome), newState.commands)
}
@Test
fun `failed login shows error`() {
val state = LoginState(loginResult = Async.Determining)
val newState = loginReducer(state, LoginAsyncIntent.LoginCompleted(success = false))
assertEquals(Async.Determined(false), newState.loginResult)
assertEquals(listOf(LoginViewCommand.ShowInvalidCredentialsError), newState.commands)
}
}The Async<T> type provides explicit modeling of asynchronous operations:
data class ProfileState(
val userData: Async<User> = Async.Determining,
// ...
) : MviState<ProfileState, ProfileCommand>
// In your UI
when (viewState.userData) {
Async.Determining -> LoadingIndicator()
is Async.Determined -> UserProfile(viewState.userData.value)
}The Scopes helper bundles coroutine dispatchers for consistent concurrency management:
val scopes = Scopes(
mainDispatcher = Dispatchers.Main,
logicDispatcher = Dispatchers.Default,
ioDispatcher = Dispatchers.IO
)
// In ViewModel
viewModelScopes.io.launch { /* I/O work */ }
viewModelScopes.logic.launch { /* CPU work */ }User and async intents have different backpressure strategies:
This ensures the UI remains responsive under load while internal operations maintain correctness.
Commands are automatically cleared between state emissions, but repeated commands are supported via a heartbeat mechanism. This ensures navigation commands always fire, even if they're identical to previous commands.
Configured for Kotlin Multiplatform:
Requirements:
./gradlew build
./gradlew testThis software is released under the LGPL License. See LICENSE.md for details.