
Minimal state management library enhances Compose projects with MVVM architecture, offering base classes for managing UI state, handling actions, and emitting side effects efficiently.
A minimal, type-safe state management library for Compose Multiplatform, built on the MVI (Model-View-Intent) pattern.
AppError)androidx.lifecycle.ViewModel with proper coroutine scoping| Module | Artifact | Purpose |
|---|---|---|
| core | com.helloanwar.mvvmate:core |
State management, actions, side effects |
| testing | com.helloanwar.mvvmate:testing |
Flow testing DSL for ViewModels with turbine
|
| forms | com.helloanwar.mvvmate:forms |
Declarative, type-safe form validation for UiState |
| network | com.helloanwar.mvvmate:network |
Network calls with retry, timeout, cancellation |
| actions | com.helloanwar.mvvmate:actions |
Serial, parallel, chained, batch action dispatching |
| network-actions | com.helloanwar.mvvmate:network-actions |
Combined network + actions capabilities |
| Platform | Status |
|---|---|
| Android | ✅ |
| iOS (arm64, x64, simulatorArm64) | ✅ |
| Desktop (JVM) | ✅ |
| Web (WasmJS) | ✅ |
Add the modules you need to your build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
// Core (required)
implementation("com.helloanwar.mvvmate:core:<version>")
// Optional modules — pick what you need
implementation("com.helloanwar.mvvmate:network:<version>")
implementation("com.helloanwar.mvvmate:actions:<version>")
implementation("com.helloanwar.mvvmate:network-actions:<version>")
}
}
}Check Maven Central for the latest version.
data class CounterState(
val count: Int = 0,
val isLoading: Boolean = false
) : UiState
sealed interface CounterAction : UiAction {
data object Increment : CounterAction
data object Decrement : CounterAction
data object Reset : CounterAction
}class CounterViewModel : BaseViewModel<CounterState, CounterAction>(
initialState = CounterState()
) {
override suspend fun onAction(action: CounterAction) {
when (action) {
CounterAction.Increment -> updateState { copy(count = count + 1) }
CounterAction.Decrement -> updateState { copy(count = count - 1) }
CounterAction.Reset -> updateState { copy(count = 0) }
}
}
}@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Count: ${state.count}", style = MaterialTheme.typography.headlineLarge)
Row {
Button(onClick = { viewModel.handleAction(CounterAction.Decrement) }) {
Text("-")
}
Button(onClick = { viewModel.handleAction(CounterAction.Increment) }) {
Text("+")
}
}
TextButton(onClick = { viewModel.handleAction(CounterAction.Reset) }) {
Text("Reset")
}
}
}┌─────────────────────────────────────────────┐
│ Composable UI │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ state │◄────────│ collectAsState() │ │
│ └──────────┘ └──────────────────┘ │
│ ▲ │ │
│ │ handleAction() │
│ │ ▼ │
│ ┌────┴─────────────────────────────┐ │
│ │ ViewModel │ │
│ │ ┌─────────┐ ┌─────────────┐ │ │
│ │ │ State │ │ onAction() │ │ │
│ │ │ Flow │◄──│ (reducer) │ │ │
│ │ └─────────┘ └─────────────┘ │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ Side Effects (opt.) │───┼───────│
│ │ └──────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────┘
For one-time events like navigation, toasts, or dialogs:
class LoginViewModel : BaseViewModelWithEffect<LoginState, LoginAction, LoginEffect>(
initialState = LoginState()
) {
override suspend fun onAction(action: LoginAction) {
when (action) {
is LoginAction.Submit -> {
updateState { copy(isLoading = true) }
val success = authRepo.login(action.email, action.password)
updateState { copy(isLoading = false) }
if (success) {
emitSideEffect(LoginEffect.NavigateToHome)
} else {
emitSideEffect(LoginEffect.ShowError("Invalid credentials"))
}
}
}
}
}MVVMate provides a robust flow-testing DSL based on CashApp's Turbine framework using the testing artifact.
Add the optional dependency:
kotlin {
sourceSets {
commonTest.dependencies {
implementation("com.helloanwar.mvvmate:testing:<version>")
}
}
}For a simple BaseViewModel containing only states and actions:
@Test
fun testCounterViewModel() = runTest {
val viewModel = CounterViewModel()
viewModel.test {
// Automatically skips the initial emitted state or you can assert it:
expectStateEquals(CounterState(count = 0))
dispatchAction(CounterAction.Increment)
expectStateEquals(CounterState(count = 1))
dispatchAction(CounterAction.Decrement)
expectState { it.count == 0 } // Assert lambda
}
}For a BaseViewModelWithEffect, you can assert State and Effect emissions in chronological order:
@Test
fun testLoginViewModel() = runTest {
val viewModel = LoginViewModel()
viewModel.testEffects {
// Assert initial state
expectState { !it.isLoading }
// Start operation
dispatchAction(LoginAction.Submit("test@test.com", "pass"))
expectState { it.isLoading }
// Wait for the simulated effect, assert exact type
val effect = expectEffectClass<LoginEffect.NavigateToHome>()
// Assert ending state
expectState { !it.isLoading }
}
}MVVMate provides a declarative, type-safe validation system through the forms module.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.helloanwar.mvvmate:forms:<version>")
}
}
}Use FormField<T> inside your UiState:
data class RegistrationState(
val email: FormField<String> = FormField(""),
val age: FormField<String> = FormField(""),
val isSubmitting: Boolean = false
) : UiState {
val isFormValid: Boolean get() = email.isValid && age.isValid
}Use setValue and built-in validators to cleanly update state and run validation rules inline:
class RegistrationViewModel : BaseViewModel<RegistrationState, RegistrationAction>(RegistrationState()) {
override suspend fun onAction(action: RegistrationAction) {
when (action) {
is RegistrationAction.EmailChanged -> updateState {
copy(email = email.setValue(
newValue = action.email,
Validators.required(),
Validators.email()
))
}
is RegistrationAction.AgeChanged -> updateState {
copy(age = age.setValue(
newValue = action.age,
Validators.required(),
Validators.digitsRequired("Must be a valid number")
))
}
RegistrationAction.Submit -> {
if (state.value.isFormValid) {
// Proceed with submission
} else {
// Mark all fields as touched to show errors
updateState {
copy(
email = email.markTouched(Validators.required(), Validators.email()),
age = age.markTouched(Validators.required(), Validators.digitsRequired("Must be a valid number"))
)
}
}
}
}
}
}class ProductsViewModel : BaseNetworkViewModel<ProductsState, ProductsAction>(
initialState = ProductsState()
) {
override suspend fun onAction(action: ProductsAction) {
when (action) {
ProductsAction.Load -> loadProducts()
}
}
private suspend fun loadProducts() {
performNetworkCallWithRetry<List<Product>>(
retries = 3,
isGlobal = true,
onSuccess = { updateState { copy(products = it) } },
onError = { error -> updateState { copy(error = error.message) } },
networkCall = { api.getProducts() }
)
}
override fun ProductsState.setGlobalLoadingState() = copy(isLoading = true)
override fun ProductsState.resetGlobalLoadingState() = copy(isLoading = false)
}| Guide | Description |
|---|---|
| Core Guide | BaseViewModel, BaseViewModelWithEffect, contracts, error handling |
| Network Guide | Retry, timeout, cancellation, loading state management, typed errors |
| Actions Guide | Serial, parallel, chained, batch action dispatching |
| Best Practices | Architecture, state design, testing, logging, Compose integration |
MVVMate includes a pluggable logging system. Enable it during development:
// In your Application.onCreate() or main():
MvvMate.logger = PrintLogger // Built-in console logger
MvvMate.isDebug = true // Enable state change loggingYou can use the built-in MvvMateAiLogger to maintain a secure, GDPR-compliant ring buffer of chronological actions, states, networking, and side effects. If a crash occurs, you instantly get a perfect, human-readable timeline to feed into an LLM or logging service:
val aiLogger = MvvMateAiLogger(
delegate = PrintLogger, // also print to console
maxHistorySize = 50,
// Safely redacts emails, tokens, and credit cards from the final string output
redactor = RegexPrivacyRedactor(RegexPrivacyRedactor.DefaultPatterns())
)
MvvMate.logger = aiLogger
// When a crash occurs:
val crashContextString = aiLogger.takeRedactedSnapshotString()Want to let an AI "drive" your app? The AiActionBridge connects an LLM directly to your BaseViewModel.
It includes a strict AiActionPolicy to ensure the LLM can only execute safe, whitelisted actions, preventing it from doing things like deleting accounts or triggering payments.
// 1. Define a security policy
val safePolicy = object : AiActionPolicy<MyState, MyAction> {
override fun isActionAllowed(action: MyAction, currentState: MyState): Boolean {
// AI is strictly FORBIDDEN from deleting accounts or checking out
return action !is MyAction.DeleteAccount && action !is MyAction.Checkout
}
}
// 2. Attach Bridge
val bridge = AiActionBridge(
viewModel = myViewModel,
policy = safePolicy,
parser = MyJsonActionParser() // Convert LLM strings to UiAction
)
// 3. Receive LLM Command
// Example AI generated JSON: { "type": "Increment" }
bridge.dispatch(llmJsonOutput)Or implement MvvMateLogger interface for custom integrations (Timber, Napier, etc.).
See Best Practices → Logging for details.
Full API docs generated by Dokka: anwarpro.github.io/mvvmate
Contributions are welcome! Please see the issues tab for areas where help is needed.
MIT License
Copyright (c) 2024 Mohammad Anwar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
A minimal, type-safe state management library for Compose Multiplatform, built on the MVI (Model-View-Intent) pattern.
AppError)androidx.lifecycle.ViewModel with proper coroutine scoping| Module | Artifact | Purpose |
|---|---|---|
| core | com.helloanwar.mvvmate:core |
State management, actions, side effects |
| testing | com.helloanwar.mvvmate:testing |
Flow testing DSL for ViewModels with turbine
|
| forms | com.helloanwar.mvvmate:forms |
Declarative, type-safe form validation for UiState |
| network | com.helloanwar.mvvmate:network |
Network calls with retry, timeout, cancellation |
| actions | com.helloanwar.mvvmate:actions |
Serial, parallel, chained, batch action dispatching |
| network-actions | com.helloanwar.mvvmate:network-actions |
Combined network + actions capabilities |
| Platform | Status |
|---|---|
| Android | ✅ |
| iOS (arm64, x64, simulatorArm64) | ✅ |
| Desktop (JVM) | ✅ |
| Web (WasmJS) | ✅ |
Add the modules you need to your build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
// Core (required)
implementation("com.helloanwar.mvvmate:core:<version>")
// Optional modules — pick what you need
implementation("com.helloanwar.mvvmate:network:<version>")
implementation("com.helloanwar.mvvmate:actions:<version>")
implementation("com.helloanwar.mvvmate:network-actions:<version>")
}
}
}Check Maven Central for the latest version.
data class CounterState(
val count: Int = 0,
val isLoading: Boolean = false
) : UiState
sealed interface CounterAction : UiAction {
data object Increment : CounterAction
data object Decrement : CounterAction
data object Reset : CounterAction
}class CounterViewModel : BaseViewModel<CounterState, CounterAction>(
initialState = CounterState()
) {
override suspend fun onAction(action: CounterAction) {
when (action) {
CounterAction.Increment -> updateState { copy(count = count + 1) }
CounterAction.Decrement -> updateState { copy(count = count - 1) }
CounterAction.Reset -> updateState { copy(count = 0) }
}
}
}@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Count: ${state.count}", style = MaterialTheme.typography.headlineLarge)
Row {
Button(onClick = { viewModel.handleAction(CounterAction.Decrement) }) {
Text("-")
}
Button(onClick = { viewModel.handleAction(CounterAction.Increment) }) {
Text("+")
}
}
TextButton(onClick = { viewModel.handleAction(CounterAction.Reset) }) {
Text("Reset")
}
}
}┌─────────────────────────────────────────────┐
│ Composable UI │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ state │◄────────│ collectAsState() │ │
│ └──────────┘ └──────────────────┘ │
│ ▲ │ │
│ │ handleAction() │
│ │ ▼ │
│ ┌────┴─────────────────────────────┐ │
│ │ ViewModel │ │
│ │ ┌─────────┐ ┌─────────────┐ │ │
│ │ │ State │ │ onAction() │ │ │
│ │ │ Flow │◄──│ (reducer) │ │ │
│ │ └─────────┘ └─────────────┘ │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ Side Effects (opt.) │───┼───────│
│ │ └──────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────┘
For one-time events like navigation, toasts, or dialogs:
class LoginViewModel : BaseViewModelWithEffect<LoginState, LoginAction, LoginEffect>(
initialState = LoginState()
) {
override suspend fun onAction(action: LoginAction) {
when (action) {
is LoginAction.Submit -> {
updateState { copy(isLoading = true) }
val success = authRepo.login(action.email, action.password)
updateState { copy(isLoading = false) }
if (success) {
emitSideEffect(LoginEffect.NavigateToHome)
} else {
emitSideEffect(LoginEffect.ShowError("Invalid credentials"))
}
}
}
}
}MVVMate provides a robust flow-testing DSL based on CashApp's Turbine framework using the testing artifact.
Add the optional dependency:
kotlin {
sourceSets {
commonTest.dependencies {
implementation("com.helloanwar.mvvmate:testing:<version>")
}
}
}For a simple BaseViewModel containing only states and actions:
@Test
fun testCounterViewModel() = runTest {
val viewModel = CounterViewModel()
viewModel.test {
// Automatically skips the initial emitted state or you can assert it:
expectStateEquals(CounterState(count = 0))
dispatchAction(CounterAction.Increment)
expectStateEquals(CounterState(count = 1))
dispatchAction(CounterAction.Decrement)
expectState { it.count == 0 } // Assert lambda
}
}For a BaseViewModelWithEffect, you can assert State and Effect emissions in chronological order:
@Test
fun testLoginViewModel() = runTest {
val viewModel = LoginViewModel()
viewModel.testEffects {
// Assert initial state
expectState { !it.isLoading }
// Start operation
dispatchAction(LoginAction.Submit("test@test.com", "pass"))
expectState { it.isLoading }
// Wait for the simulated effect, assert exact type
val effect = expectEffectClass<LoginEffect.NavigateToHome>()
// Assert ending state
expectState { !it.isLoading }
}
}MVVMate provides a declarative, type-safe validation system through the forms module.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.helloanwar.mvvmate:forms:<version>")
}
}
}Use FormField<T> inside your UiState:
data class RegistrationState(
val email: FormField<String> = FormField(""),
val age: FormField<String> = FormField(""),
val isSubmitting: Boolean = false
) : UiState {
val isFormValid: Boolean get() = email.isValid && age.isValid
}Use setValue and built-in validators to cleanly update state and run validation rules inline:
class RegistrationViewModel : BaseViewModel<RegistrationState, RegistrationAction>(RegistrationState()) {
override suspend fun onAction(action: RegistrationAction) {
when (action) {
is RegistrationAction.EmailChanged -> updateState {
copy(email = email.setValue(
newValue = action.email,
Validators.required(),
Validators.email()
))
}
is RegistrationAction.AgeChanged -> updateState {
copy(age = age.setValue(
newValue = action.age,
Validators.required(),
Validators.digitsRequired("Must be a valid number")
))
}
RegistrationAction.Submit -> {
if (state.value.isFormValid) {
// Proceed with submission
} else {
// Mark all fields as touched to show errors
updateState {
copy(
email = email.markTouched(Validators.required(), Validators.email()),
age = age.markTouched(Validators.required(), Validators.digitsRequired("Must be a valid number"))
)
}
}
}
}
}
}class ProductsViewModel : BaseNetworkViewModel<ProductsState, ProductsAction>(
initialState = ProductsState()
) {
override suspend fun onAction(action: ProductsAction) {
when (action) {
ProductsAction.Load -> loadProducts()
}
}
private suspend fun loadProducts() {
performNetworkCallWithRetry<List<Product>>(
retries = 3,
isGlobal = true,
onSuccess = { updateState { copy(products = it) } },
onError = { error -> updateState { copy(error = error.message) } },
networkCall = { api.getProducts() }
)
}
override fun ProductsState.setGlobalLoadingState() = copy(isLoading = true)
override fun ProductsState.resetGlobalLoadingState() = copy(isLoading = false)
}| Guide | Description |
|---|---|
| Core Guide | BaseViewModel, BaseViewModelWithEffect, contracts, error handling |
| Network Guide | Retry, timeout, cancellation, loading state management, typed errors |
| Actions Guide | Serial, parallel, chained, batch action dispatching |
| Best Practices | Architecture, state design, testing, logging, Compose integration |
MVVMate includes a pluggable logging system. Enable it during development:
// In your Application.onCreate() or main():
MvvMate.logger = PrintLogger // Built-in console logger
MvvMate.isDebug = true // Enable state change loggingYou can use the built-in MvvMateAiLogger to maintain a secure, GDPR-compliant ring buffer of chronological actions, states, networking, and side effects. If a crash occurs, you instantly get a perfect, human-readable timeline to feed into an LLM or logging service:
val aiLogger = MvvMateAiLogger(
delegate = PrintLogger, // also print to console
maxHistorySize = 50,
// Safely redacts emails, tokens, and credit cards from the final string output
redactor = RegexPrivacyRedactor(RegexPrivacyRedactor.DefaultPatterns())
)
MvvMate.logger = aiLogger
// When a crash occurs:
val crashContextString = aiLogger.takeRedactedSnapshotString()Want to let an AI "drive" your app? The AiActionBridge connects an LLM directly to your BaseViewModel.
It includes a strict AiActionPolicy to ensure the LLM can only execute safe, whitelisted actions, preventing it from doing things like deleting accounts or triggering payments.
// 1. Define a security policy
val safePolicy = object : AiActionPolicy<MyState, MyAction> {
override fun isActionAllowed(action: MyAction, currentState: MyState): Boolean {
// AI is strictly FORBIDDEN from deleting accounts or checking out
return action !is MyAction.DeleteAccount && action !is MyAction.Checkout
}
}
// 2. Attach Bridge
val bridge = AiActionBridge(
viewModel = myViewModel,
policy = safePolicy,
parser = MyJsonActionParser() // Convert LLM strings to UiAction
)
// 3. Receive LLM Command
// Example AI generated JSON: { "type": "Increment" }
bridge.dispatch(llmJsonOutput)Or implement MvvMateLogger interface for custom integrations (Timber, Napier, etc.).
See Best Practices → Logging for details.
Full API docs generated by Dokka: anwarpro.github.io/mvvmate
Contributions are welcome! Please see the issues tab for areas where help is needed.
MIT License
Copyright (c) 2024 Mohammad Anwar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.