Android Architecture series · Part 4 of 9

MVVM and Redux (MVI): State Flows to the View

Khairul Alam·October 2026·~11 min read
MVVMReduxMVIStateFlowCompose

The modern pair. Instead of telling the view what to do, the logic publishes state and the view draws it. MVVM is Google's mainstream choice; Redux/MVI is the same idea with stricter rules.

In 60 seconds
  • MVVM: ViewModel exposes state, view observes, no view reference.
  • Redux/MVI: one state, actions, a pure reducer, one-way data flow.
  • Both: survive rotation and suit Jetpack Compose.
  • Choose: MVVM by default, reducer style for complex screens.
Contents
  1. MVVM: the view watches the state
  2. Redux / MVI: one state, one direction
  3. MVVM or MVI

1. MVVM: the view watches the state

In MVP the presenter tells the view what to do (“showLoading!”). In Model View ViewModel the ViewModel just publishes state and the view observes it. The ViewModel does not know the view exists, so there is no attach/detach dance and no leaked Activity.

ViewActivity / ComposeViewModelholds UI stateModelrepository, dataeventsstate (observe)callsThe arrow back to the view is observation, not a direct call.
The ViewModel never references the view. The view subscribes to a stream of state.

State as a single object

sealed interface ProfileUiState {
    data object Loading : ProfileUiState
    data class Content(val profile: Profile) : ProfileUiState
    data class Error(val message: String) : ProfileUiState
}

The ViewModel

@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val repo: ProfileRepository,
    private val dispatchers: DispatcherProvider,
) : ViewModel() {

    private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val state: StateFlow<ProfileUiState> = _state.asStateFlow()

    fun load(id: String) {
        viewModelScope.launch {
            _state.value = ProfileUiState.Loading
            _state.value = try {
                ProfileUiState.Content(withContext(dispatchers.io) { repo.getProfile(id) })
            } catch (e: IOException) {
                ProfileUiState.Error("No connection")
            }
        }
    }
}

The view

The ViewProxy from Part 2 gets one extra method, render(state), that maps a state to the right widgets. The Activity only connects the stream to it.

fun ProfileViewProxy.render(state: ProfileUiState) = when (state) {
    ProfileUiState.Loading -> showLoading()
    is ProfileUiState.Content -> showProfile(state.profile)
    is ProfileUiState.Error -> showError(state.message)
}
class ProfileActivity : AppCompatActivity() {
    private val viewModel: ProfileViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityProfileBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val proxy = ProfileViewProxy(binding)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {   // stops when screen is hidden
                viewModel.state.collect { proxy.render(it) }
            }
        }
        binding.load.setOnClickListener { viewModel.load("42") }
    }
}

The ViewModel survives rotation, so the loaded profile stays on screen when the user turns the phone (more in Part 7). In Jetpack Compose the same idea is one line: val state by viewModel.state.collectAsStateWithLifecycle().

GoodWatch out
Official Jetpack support: ViewModel, StateFlow, SavedStateHandle.If the state is many separate LiveData fields, they can disagree. Prefer one state object.
No view reference, so no leak and no detach.One-time events (show a toast, navigate) do not fit state well. Model them explicitly (a channel or a consumed-flag in state).
Survives configuration changes.Too much code in the ViewModel. Split into use cases.

2. Redux / MVI: one state, one direction

Redux (from the web world) and MVI (Model View Intent) push the MVVM idea further. The rules:

  1. The whole screen is described by one immutable State.
  2. The user (or the system) can only send Actions (also called Intents).
  3. A Reducer is a pure function: (oldState, action) -> newState.
  4. A Store holds the current state and runs the reducer.
  5. The View renders the state. Nothing else changes the state.
Viewrender(state)ActionLoad, Loaded, FailedReducerpure functionStoreholds Statenew State flows back to the View (one direction only)unidirectional data flow
Data goes around a loop in one direction. That makes every state change easy to trace and replay.
data class ProfileState(
    val loading: Boolean = false,
    val profile: Profile? = null,
    val error: String? = null,
)

sealed interface ProfileAction {
    data class Load(val id: String) : ProfileAction
    data class Loaded(val profile: Profile) : ProfileAction
    data class Failed(val message: String) : ProfileAction
}

// Pure: same input, same output, no network, no Android.
fun reduce(state: ProfileState, action: ProfileAction): ProfileState = when (action) {
    is ProfileAction.Load   -> state.copy(loading = true, error = null)
    is ProfileAction.Loaded -> state.copy(loading = false, profile = action.profile)
    is ProfileAction.Failed -> state.copy(loading = false, error = action.message)
}

class ProfileStore(
    private val repo: ProfileRepository,
    private val scope: CoroutineScope,
) {
    private val _state = MutableStateFlow(ProfileState())
    val state: StateFlow<ProfileState> = _state

    fun dispatch(action: ProfileAction) {
        _state.update { reduce(it, action) }
        if (action is ProfileAction.Load) {          // side effect lives outside the reducer
            scope.launch {
                runCatching { repo.getProfile(action.id) }
                    .onSuccess { dispatch(ProfileAction.Loaded(it)) }
                    .onFailure { dispatch(ProfileAction.Failed("No connection")) }
            }
        }
    }
}

The reducer is the star: because it is a pure function, you can test every state change with a one-line assertion and log the list of actions to replay a bug exactly. The cost is more types (actions, state) and more ceremony for simple screens. Compose fits this style very well, since a composable is already f(state) -> UI.

3. MVVM or MVI?

They are close cousins. MVVM with a single StateFlow<UiState> and a onEvent(event) function is basically MVI. The difference is how strict you are:

MVVMRedux / MVI
StateOne or several observable fieldsExactly one immutable object
Changing stateViewModel methods set valuesOnly a reducer, driven by actions
DebuggingRead the methodsReplay the action log
CeremonyLowHigher
Beginner advice

Start with MVVM and one state object. If the screen has many interacting states (a form, a player, a chat), move that screen to the stricter reducer style.

← Back to all writing