Implementing Initial Data Loading with MVI: Best Practices and Code Solutions.

Implementing Initial Data Loading with MVI: Best Practices and Code Solutions.

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() {}
}        

  • by lazy - Ensures the onStart block only executes when the state is first accessed, not on every reference.
  • onStart - Triggers the initial data load when the StateFlow starts being collected, aligning with the first subscriber (e.g., the UI).
  • stateIn - Converts the Flow to a StateFlow, tying it to the viewModelScope and providing an initial value. The WhileSubscribed(5000L) parameter keeps the flow active for 5 seconds after the last subscriber disappears—matching Android’s ANR deadline—to handle brief configuration changes gracefully.
  • initialDataLoad - An open suspend function that concrete ViewModels can override to define their specific data-loading logic.

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:

  1. Calls a use case to fetch data within a coroutine (thanks to viewModelScope in the base class).
  2. Collects the result and updates the MVI state via an event.
  3. Executes this logic only when the state is first collected, ensuring efficiency.

Why This Works

  • Loads lazily - Data fetching happens only when the UI subscribes to the state, not at ViewModel creation.
  • Survives configuration changes - Unlike LaunchedEffect, it does not reload unnecessarily.
  • Supports testing - You can instantiate the ViewModel, then trigger state collection to test data loading in isolation.
  • Remains flexible - The open initialDataLoad function lets each ViewModel define its own logic without forcing unnecessary overrides.

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

  1. Loading Initial Data properly with MVI by Maxime Michel
  2. Exploring MVI Principles and Unidirectional Data Flow with Jetpack Compose by Ramadan Sayed
  3. Unlocking the Power of MVI and MVVM Architectures: A Comprehensive Guide for Android Developers by Prashant Singh




要查看或添加评论,请登录

Denis Koome的更多文章

社区洞察