Android Architecture series · Part 4 of 9
MVVM and Redux (MVI): State Flows to the View
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.
- 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.
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.
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().
| Good | Watch 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:
- The whole screen is described by one immutable State.
- The user (or the system) can only send Actions (also called Intents).
- A Reducer is a pure function:
(oldState, action) -> newState. - A Store holds the current state and runs the reducer.
- The View renders the state. Nothing else changes the state.
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:
| MVVM | Redux / MVI | |
|---|---|---|
| State | One or several observable fields | Exactly one immutable object |
| Changing state | ViewModel methods set values | Only a reducer, driven by actions |
| Debugging | Read the methods | Replay the action log |
| Ceremony | Low | Higher |
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.