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

Easy 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

ComponentEasy meaningExamples
ViewDisplays the current stateActivity, Fragment, Compose screen
IntentRepresents a user actionLoad, Retry, Search, Submit
StateRepresents the complete screenLoading, data, error
ViewModelProcesses Intent and updates StateProductViewModel
EffectOne-time actionNavigation, 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 --> V

For example:

User clicks Retry
      ↓
View sends Retry Intent
      ↓
ViewModel processes Retry
      ↓
ViewModel loads products
      ↓
ViewModel creates new State
      ↓
View displays products

The 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")
SubmitPayment
flowchart 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 IntentAndroid Intent
Represents a UI actionStarts Activity, Service or sends Broadcast
Part of MVI patternAndroid framework class
Example: RetryExample: 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: Updated

State 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| V

Compose 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 State

StateFlow 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 Intent

The 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 result

The 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 UI

Complete 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| V

Data types

sealed interface ProductIntent
data class ProductState(...)
sealed interface ProductEffect

ViewModel 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.Retry

Android 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 = true

Correct

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
RefreshProducts

Avoid 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

PointMVVMMVI
Data directionCan be multiple directionsStrictly one direction
User actionMethod callsUsually represented as Intent
UI dataOne or multiple observable valuesUsually one complete State
State updatesViewModel updates propertiesNew immutable State is created
ReducerNot requiredOften used
One-time actionsEventsEffects
Best forSimple to medium screensComplex state-heavy screens
BoilerplateLessMore

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 State

Error case:

LoadProducts Intent
        ↓
Loading State
        ↓
Error State

Reducer 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 → View

The 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 + Result

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