Student-Friendly Tutorial with Lifecycle, Internal Working, Pros, Cons, Diagrams, and Use Cases
MVI
1. What is MVI?
MVI stands for:
* Model
* View
* Intent
MVI is an architecture pattern where UI actions and state move in a single direction.
This is called Unidirectional Data Flow.
flowchart LR
V["View"] --> I["Intent"]
I --> VM["ViewModel"]
VM --> S["New State"]
S --> VEasy memory trick
* Intent: What the user wants to do
* ViewModel: Processes the action
* State: What the screen should show
* View: Displays the state
> User sends an Intent, ViewModel creates a new State, and View displays it.
---
2. Main MVI Components
| Component | Easy meaning | Examples |
|---|---|---|
| View | Displays the current state | Activity, Fragment, Compose screen |
| Intent | Represents a user action | Load, Retry, Search, Submit |
| State | Represents the complete screen | Loading, data, error |
| ViewModel | Processes Intent and updates State | ProductViewModel |
| Effect | One-time action | Navigation, Snackbar, Toast |
---
3. Unidirectional Data Flow
In MVI, data moves in one direction.
flowchart TD
U["User Action"] --> V["View"]
V --> I["Intent"]
I --> VM["ViewModel"]
VM --> S["New State"]
S --> VFor example:
User clicks Retry
↓
View sends Retry Intent
↓
ViewModel processes Retry
↓
ViewModel loads products
↓
ViewModel creates new State
↓
View displays productsThe View cannot directly modify the State.
---
4. MVI Product Example
Suppose a product screen has these actions:
* Load products
* Retry loading
* Select a product
* Refresh products
First, define the user Intents.
sealed interface ProductIntent {
data object LoadProducts : ProductIntent
data object Retry : ProductIntent
data object Refresh : ProductIntent
data class SelectProduct(
val productId: String
) : ProductIntent
}Each Intent clearly describes what happened on the screen.
---
5. Intent
An Intent represents a user action or UI request.
Examples:
LoadProducts
Retry
Refresh
Search("phone")
SelectProduct("101")
SubmitPaymentflowchart LR
C["Button Click"] --> I1["Retry Intent"]
T["Text Input"] --> I2["Search Intent"]
P["Item Click"] --> I3["Select Intent"]Important clarification
MVI Intent is not the same as Android Intent.
| MVI Intent | Android Intent |
|---|---|
| Represents a UI action | Starts Activity, Service or sends Broadcast |
| Part of MVI pattern | Android framework class |
Example: Retry | Example: Intent(this, DetailActivity::class.java) |
---
6. State
State contains all the information needed to draw the screen.
data class ProductState(
val isLoading: Boolean = false,
val products: List<Product> = emptyList(),
val error: String? = null,
val isRefreshing: Boolean = false
)The View receives one State object and displays it.
stateDiagram-v2
[*] --> Initial
Initial --> Loading: Load
Loading --> Success: Products received
Loading --> Error: Request failed
Error --> Loading: Retry
Success --> Refreshing: Refresh
Refreshing --> Success: UpdatedState is immutable
We do not change the existing State object directly.
We create a new State using `copy()`:_uiState.update { oldState ->
oldState.copy(
isLoading = true,
error = null
)
}flowchart LR
OS["Old State"] --> C["copy with changes"]
C --> NS["New State"]---
7. ViewModel
The ViewModel:
* Receives Intents
* Decides what operation to perform
* Loads or processes data
* Creates a new State
* Sends Effects when required
flowchart TD
I["Intent"] --> VM["ViewModel"]
VM --> P["Process Action"]
P --> S["Update State"]
S --> V["View"]Basic ViewModel structure
class ProductViewModel(
private val model: ProductModel
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductState())
val uiState = _uiState.asStateFlow()
fun onIntent(intent: ProductIntent) {
when (intent) {
ProductIntent.LoadProducts -> loadProducts()
ProductIntent.Retry -> loadProducts()
ProductIntent.Refresh -> refreshProducts()
is ProductIntent.SelectProduct -> {
selectProduct(intent.productId)
}
}
}
}The View sends every action through one entry function:
viewModel.onIntent(ProductIntent.Retry)---
8. View
The View has two main responsibilities:
1. Send Intents
2. Display State
flowchart LR
V["View"] -->|Send Intent| VM["ViewModel"]
VM -->|Expose State| VCompose example
@Composable
fun ProductScreen(
viewModel: ProductViewModel
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ProductContent(
state = state,
)
}@Composable
fun ProductContent(
state: ProductState,
onIntent: (ProductIntent) -> Unit
) {
when {
state.isLoading -> {
CircularProgressIndicator()
}
state.error != null -> {
Column {
Text(state.error)
Button(
onIntent(ProductIntent.Retry)
}
) {
Text("Retry")
}
}
}
else -> {
LazyColumn {
items(state.products) { product ->
Text(
text = product.name,
modifier = Modifier.clickable {
onIntent(
ProductIntent.SelectProduct(product.id)
)
}
)
}
}
}
}
}The View does not contain request-handling logic.
---
9. Internal Working of MVI
Let us understand what happens when the user opens a product screen.Step 1: View observes State
The Compose screen starts collecting State from the ViewModel.
val state by viewModel.uiState.collectAsStateWithLifecycle()sequenceDiagram
participant V as View
participant SF as StateFlow
participant VM as ViewModel
V->>SF: Start collecting
SF-->>V: Send current State
VM->>SF: Publish new State
SF-->>V: Send updated StateStateFlow always stores the latest State.
---
Step 2: View sends an Intent
When the screen opens:
LaunchedEffect(Unit) {
viewModel.onIntent(ProductIntent.LoadProducts)
}sequenceDiagram
participant V as View
participant VM as ViewModel
V->>VM: LoadProducts IntentThe Intent tells the ViewModel:
> The screen wants to load products.
---
Step 3: ViewModel identifies the Intent
The onIntent() function checks the Intent type.
fun onIntent(intent: ProductIntent) {
when (intent) {
ProductIntent.LoadProducts -> loadProducts()
ProductIntent.Retry -> loadProducts()
ProductIntent.Refresh -> refreshProducts()
is ProductIntent.SelectProduct -> {
selectProduct(intent.productId)
}
}
}flowchart TD
I["Intent received"] --> C{"Intent type?"}
C -->|Load| L["Load products"]
C -->|Retry| R["Retry request"]
C -->|Refresh| F["Refresh data"]
C -->|Select| S["Select product"]---
Step 4: ViewModel Creates Loading State
The ViewModel uses the old State to create a new loading State.
_uiState.update { currentState ->
currentState.copy(
isLoading = true,
error = null
)
}flowchart LR
OS["Old State"] --> R["State Update"]
R --> LS["Loading State"]
LS --> V["View shows loader"]The old State is not modified. A new State object is created.
---
Step 5: StateFlow Sends Loading State
StateFlow stores and emits the new State.
ProductState(
isLoading = true,
products = [],
error = null
)The View receives it and displays the progress indicator.
sequenceDiagram
participant VM as ViewModel
participant SF as StateFlow
participant V as View
VM->>SF: Emit Loading State
SF-->>V: Loading State
V->>V: Show progress---
Step 6: ViewModel Performs the Operation
The ViewModel starts a coroutine:
viewModelScope.launch {
val products = model.getProducts()
}sequenceDiagram
participant VM as ViewModel
participant M as Model
participant DS as Data Source
VM->>M: getProducts()
M->>DS: Load data
DS-->>M: Products or error
M-->>VM: Return resultThe data might come from:
* Network
* Database
* Cache
* Local storage
---
Step 7: Result Becomes a New State
If the request succeeds:
_uiState.update { currentState ->
currentState.copy(
isLoading = false,
products = products,
error = null
)
}If it fails:
_uiState.update { currentState ->
currentState.copy(
isLoading = false,
error = "Unable to load products"
)
}flowchart TD
R{"Operation result"}
R -->|Success| SS["Success State"]
R -->|Failure| ES["Error State"]
SS --> SF["StateFlow"]
ES --> SF
SF --> V["View"]---
Step 8: View Renders the New State
When Compose receives the new State:
1. State value changes.
2. Compose schedules recomposition.
3. The composable reads the latest State.
4. It displays the product list or error.
flowchart LR
NS["New State"] --> C["Compose receives it"]
C --> RC["Recomposition"]
RC --> UI["Updated UI"]The View does not ask what changed. It renders the latest complete State.
---
10. Complete MVI Cycle
sequenceDiagram
actor U as User
participant V as View
participant VM as ViewModel
participant M as Model
participant SF as StateFlow
U->>V: Tap Load
V->>VM: LoadProducts Intent
VM->>SF: Loading State
SF-->>V: Show progress
VM->>M: Get products
M-->>VM: Products or error
VM->>SF: Success or Error State
SF-->>V: Render new UIComplete flow in easy language
1. User performs an action.
2. View converts the action into an Intent.
3. View sends the Intent to ViewModel.
4. ViewModel processes the Intent.
5. ViewModel performs the required operation.
6. ViewModel creates a new State.
7. StateFlow sends the State to View.
8. View displays the new State.The cycle repeats for every user action.
---
11. What is a Reducer?
A Reducer creates a new State from:
* Current State
* Result of an operation
flowchart LR
CS["Current State"] --> R["Reducer"]
RESULT["Result"] --> R
R --> NS["New State"]Conceptually:
New State = Reduce(Current State, Result)Result types
sealed interface ProductResult {
data object Loading : ProductResult
data class Success(
val products: List<Product>
) : ProductResult
data class Failure(
val message: String
) : ProductResult
}Reducer
fun reduce(
state: ProductState,
result: ProductResult
): ProductState {
return when (result) {
ProductResult.Loading -> {
state.copy(
isLoading = true,
error = null
)
}
is ProductResult.Success -> {
state.copy(
isLoading = false,
products = result.products,
error = null
)
}
is ProductResult.Failure -> {
state.copy(
isLoading = false,
error = result.message
)
}
}
}Using the Reducer
private fun updateState(result: ProductResult) {
_uiState.update { currentState ->
reduce(currentState, result)
}
}Why use a Reducer?
* State changes stay in one place.
* State updates become predictable.
* Reducer is easy to unit test.
* The same input always produces the same output.
A separate Reducer is useful in complex screens. A small screen can update State directly in the ViewModel.
---
12. State vs Effect
Not everything should be stored as State.
State
State represents something currently visible.
Examples:
* Loading indicator
* Product list
* Error section
* Selected product
* Button enabled or disabled
Effect
Effect represents a one-time action.
Examples:
* Navigate to details
* Show Snackbar
* Open browser
* Show Toast
* Close screen
flowchart TD
VM["ViewModel"]
VM --> S["State<br/>Persistent UI information"]
VM --> E["Effect<br/>One-time action"]Effect definition
sealed interface ProductEffect {
data class NavigateToDetails(
val productId: String
) : ProductEffect
data class ShowSnackbar(
val message: String
) : ProductEffect
}Effect channel
private val _effect = Channel<ProductEffect>()
val effect = _effect.receiveAsFlow()Sending an Effect
private fun selectProduct(productId: String) {
viewModelScope.launch {
_effect.send(
ProductEffect.NavigateToDetails(productId)
)
}
}Handling an Effect
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is ProductEffect.NavigateToDetails -> {
navController.navigate(
"product/${effect.productId}"
)
}
is ProductEffect.ShowSnackbar -> {
snackbarHostState.showSnackbar(effect.message)
}
}
}
}---
13. Complete MVI Structure
flowchart TD
V["View"] -->|Intent| VM["ViewModel"]
VM -->|Request| M["Model"]
M -->|Result| VM
VM -->|State| V
VM -->|Effect| VData types
sealed interface ProductIntent
data class ProductState(...)
sealed interface ProductEffectViewModel exposes
val uiState: StateFlow<ProductState>
val effect: Flow<ProductEffect>ViewModel receives
fun onIntent(intent: ProductIntent)---
14. Complete ViewModel Example
class ProductViewModel(
private val model: ProductModel
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductState())
val uiState = _uiState.asStateFlow()
private val _effect = Channel<ProductEffect>()
val effect = _effect.receiveAsFlow()
fun onIntent(intent: ProductIntent) {
when (intent) {
ProductIntent.LoadProducts,
ProductIntent.Retry -> loadProducts()
ProductIntent.Refresh -> refreshProducts()
is ProductIntent.SelectProduct -> {
openProduct(intent.productId)
}
}
}
private fun loadProducts() {
viewModelScope.launch {
_uiState.update {
it.copy(
isLoading = true,
error = null
)
}
try {
val products = model.getProducts()
_uiState.update {
it.copy(
isLoading = false,
products = products
)
}
} catch (exception: Exception) {
_uiState.update {
it.copy(
isLoading = false,
error = exception.message
?: "Something went wrong"
)
}
}
}
}
private fun refreshProducts() {
loadProducts()
}
private fun openProduct(productId: String) {
viewModelScope.launch {
_effect.send(
ProductEffect.NavigateToDetails(productId)
)
}
}
}---
15. Benefits of MVI
* Data moves in one direction.
* There is usually one state for the complete screen.
* State changes are predictable.
* Debugging becomes easier.
* Works well with Compose.
* Suitable for complex screens.
* Easy to test Intent-to-State transitions.
* Avoids different UI values going out of sync.
---
16. Limitations of MVI
* Creates more classes.
* Contains more boilerplate.
* Intent and State can become very large.
* Simple screens may not need full MVI.
* Effects need careful handling.
* Incorrect State design may cause unnecessary recomposition.
MVI is especially useful for screens with many actions and state transitions.
---
17. Common Mistakes
Mistake 1: Confusing MVI Intent with Android Intent
MVI Intent represents a UI action.
ProductIntent.RetryAndroid Intent starts an Android component.
Intent(this, DetailActivity::class.java)They are different concepts.
---
Mistake 2: Allowing View to Modify State
Wrong
viewModel.uiState.value.isLoading = trueCorrect
viewModel.onIntent(ProductIntent.LoadProducts)The View sends an Intent. Only ViewModel updates State.
---
Mistake 3: Exposing MutableStateFlow
Wrong
val uiState = MutableStateFlow(ProductState())Correct
private val _uiState = MutableStateFlow(ProductState())
val uiState = _uiState.asStateFlow()---
Mistake 4: Storing Navigation as Normal State
If navigation remains in State, it may happen again after recomposition or rotation.Use a one-time Effect for navigation.
ProductEffect.NavigateToDetails(productId)---
Mistake 5: Mutating the Existing State
Wrong
state.products.add(product)Correct
_uiState.update {
it.copy(
products = it.products + product
)
}Create a new immutable State.
---
Mistake 6: Creating an Incomplete State
Avoid many unrelated observable variables:
val loading: StateFlow<Boolean>
val products: StateFlow<List<Product>>
val error: StateFlow<String?>They may emit at different times.
Prefer one screen State:
val uiState: StateFlow<ProductState>---
Mistake 7: Handling the Same Intent Multiple Times
Repeated button clicks may start multiple network requests.
private fun loadProducts() {
if (_uiState.value.isLoading) return
viewModelScope.launch {
// Load data
}
}For search, cancellation or `flatMapLatest` may be used so an old search does not overwrite the latest one.---
Mistake 8: Putting Every Small Action in MVI
Not every UI change needs a complicated pipeline.
For example, a purely visual animation may be kept inside the View if it does not affect screen or business state.---
Mistake 9: One Huge State and Intent File
For a large screen, organize related actions clearly.Use meaningful names:
LoadProducts
SearchProducts
SelectProduct
RetryLoading
RefreshProductsAvoid unclear names such as:
Action1
Update
Click
DoSomething---
Mistake 10: Using Effect for Important Persistent Data
Effects are not permanent state.
Do not use an Effect for:
* Current product list
* Loading status
* Selected filter
* Form values
These belong in State.
---
18. MVI vs MVVM
| Point | MVVM | MVI |
|---|---|---|
| Data direction | Can be multiple directions | Strictly one direction |
| User action | Method calls | Usually represented as Intent |
| UI data | One or multiple observable values | Usually one complete State |
| State updates | ViewModel updates properties | New immutable State is created |
| Reducer | Not required | Often used |
| One-time actions | Events | Effects |
| Best for | Simple to medium screens | Complex state-heavy screens |
| Boilerplate | Less | More |
Simple difference
flowchart TD
MVVM["MVVM"] --> A["View calls ViewModel methods"]
MVI["MVI"] --> B["View sends Intents"]
B --> C["ViewModel creates new State"]> MVVM manages observable UI data, while MVI enforces a stricter Intent-to-State cycle.
---
19. Testing MVI
MVI testing is mainly about checking state transitions.
flowchart LR
I["Input Intent"] --> VM["ViewModel"]
VM --> S["Expected State"]Example:
LoadProducts Intent
↓
Loading State
↓
Success StateError case:
LoadProducts Intent
↓
Loading State
↓
Error StateReducer testing is simple because it uses input and output:
@Test
fun `success result creates success state`() {
val oldState = ProductState(isLoading = true)
val newState = reduce(
oldState,
ProductResult.Success(products)
)
assertFalse(newState.isLoading)
assertEquals(products, newState.products)
}---
20. Important Interview Questions and Answers
Q1. What is MVI?
MVI stands for Model-View-Intent. The View sends user actions as Intents, the ViewModel processes them and produces a new immutable State, and the View renders that State.
---
Q2. What is Unidirectional Data Flow?
It means data moves in one direction:
View → Intent → ViewModel → State → ViewThe View does not modify the State directly.
---
Q3. What is an Intent in MVI?
An Intent represents a user action or screen request, such as Load, Retry, Search, Refresh or Submit.
It is different from the Android Intent class.
---
Q4. What is State in MVI?
State is an immutable object containing all information needed to display the screen.
Examples include loading, data, error, form values and selected items.
---
Q5. Why is State immutable?
Immutable State prevents accidental changes and makes every state transition clear, predictable and easier to debug.
---
Q6. What is a Reducer?
A Reducer accepts the current State and an operation Result, then returns a new State.
New State = Current State + ResultIt should not directly change the old State.
---
Q7. What is an Effect?
An Effect is a one-time action such as navigation, showing a Snackbar or opening another screen.
Effects are separate from persistent UI State.
---
Q8. Why is MVI useful with Compose?
Compose renders UI from State. When MVI creates a new State, Compose receives it and recomposes the required UI.
Both follow a state-driven UI approach.
---
Q9. How does MVI handle errors?
The ViewModel converts a failed operation into an Error State. The View renders the error and can send a Retry Intent.
---
Q10. Can MVI work without a Reducer?
Yes. A simple ViewModel can update State directly using copy().
A separate Reducer is more helpful when the screen has many complex state transitions.
---
Q11. What is the main difference between MVVM and MVI?
MVVM generally allows the View to call different ViewModel methods and observe one or more values. MVI uses a stricter cycle where the View sends Intents and receives one immutable State.
---
Q12. What are the disadvantages of MVI?
MVI can add boilerplate through Intent, State, Result, Effect and Reducer classes. For small screens, full MVI may be unnecessary.
---
Q13. How do you prevent duplicate requests?
Check whether a request is already running, disable the action in State, debounce repeated actions or cancel the previous job when appropriate.
---
Q14. How do you test MVI?
Send an Intent to the ViewModel and verify the emitted sequence of States and Effects.
---
21. Final Interview Answer
> MVI stands for Model-View-Intent and follows unidirectional data flow. The View converts user actions into Intents and sends them to the ViewModel. The ViewModel processes the Intent, performs the required operation and creates a new immutable State. StateFlow delivers the State to the View, and the View renders it. One-time actions such as navigation and Snackbar messages are handled as Effects. For complex screens, a Reducer can convert the current State and operation Result into a new State. MVI makes state transitions predictable, testable and easy to debug.
Final Memory Diagram
flowchart LR
V["View"] -->|Intent| VM["ViewModel"]
VM -->|Operation| M["Model"]
M -->|Result| VM
VM -->|New State| V
VM -->|Effect| V> Intent says what happened, ViewModel processes it, State describes the screen, and View displays it.