Implementing Initial Data Loading with MVI: Best Practices and Code Solutions.
Denis Koome
Mobile Developer | Writer | Web 3 | AI & Tech Enthusiast | Kotlin | Flutter | React
When building Android applications using the Model-View-Intent (MVI) architecture with Jetpack Compose, loading initial data effectively is a critical implementation detail that can impact performance, testability, and maintainability. While it might be tempting to load data in a ViewModel’s init block or use LaunchedEffect in the UI layer, these approaches have their downsides. They either take away flexibility or risk reloading data too often, which can lead to inefficient performance. A better solution leverages Kotlin Flows with onStart and stateIn to ensure data is loaded optimally—only when needed, with full control and testability.
Moving Beyond the init Block and LaunchedEffect
While initializing data in a ViewModel’s init block ensures data is loaded once and survives configuration changes, it lacks flexibility. One cannot control when the data loads or easily inject test scenarios between ViewModel initialization and data fetching. Alternatively, using a LaunchedEffect in the composable gives you control over timing and simplifies testing by calling a ViewModel function explicitly. However, it reloads data on every recomposition, defeating the purpose of a ViewModel persisting across configuration changes.
Initiliazing data requires a solution that balances control, efficiency, and testability. This reccomended approach uses a StateFlow combined with onStart and stateIn to load data lazily when the state is first accessed, while respecting the ViewModel’s lifecycle and avoiding unnecessary reloads.
To achieve this, start with a base ViewModel class that sets up the state management infrastructure. The state should be a StateFlow, initialized lazily to defer data loading until it’s actually needed. Here’s how to structure it;
abstract class BaseViewModel<State : Reducer.ViewState, Event : Reducer.ViewEvent, Effect : Reducer.ViewEffect>(
val initialState: State,
private val reducer: Reducer<State, Event, Effect>
) : ViewModel() {
private val _state: MutableStateFlow<State> = MutableStateFlow(initialState)
val state: StateFlow<State> by lazy {
_state.onStart {
viewModelScope.launch {
initialDataLoad()
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = initialState
)
}
open suspend fun initialDataLoad() {}
}
Integrating with a Concrete ViewModel
Each ViewModel can now customize its data loading by overriding initialDataLoad. For example:
@HiltViewModel
class HomeViewModel @Inject constructor(
private val loadInitialDataUseCase: Lazy<LoadInitialDataUseCase>
) : BaseViewModel<HomeViewState, HomeViewEvent, HomeViewEffect>(
initialState = HomeViewState.initial(),
reducer = HomeReducer()
) {
override suspend fun initialDataLoad() {
loadInitialDataUseCase.get().invoke(Unit).collect { result ->
when (result) {
is Result.Success -> sendEvent(
event = HomeViewEvent.UpdateInitialData(data = result.data)
)
else -> Unit
}
}
}
}
Here, the HomeViewModel:
Why This Works
Enforcing Best Practices
To prevent accidental misuse—like reverting to the init block for data loading—you can use tools like Konsist to enforce rules in your codebase. For example:
fun `ViewModels should not have UseCases in init block`() {
Konsist
.scopeFromProject()
.classes()
.withNameEndingWith("ViewModel")
.withoutName("BaseViewModel")
.assertTrue { viewModel ->
viewModel.initBlocks.all { initBlock ->
!initBlock.hasTextContaining("UseCase")
}
}
}
This test ensures UseCase calls stay out of init blocks, pushing developers toward the initialDataLoad approach.
Conclusion
Loading initial data in MVI does not have to be a compromise between control and stability. By leveraging onStart and stateIn within a lazy StateFlow, you can craft a solution that is efficient and robust. This method ensures data loads precisely when needed, respects the ViewModel’s lifecycle, and keeps your architecture testable and maintainable.
References