Logo
Advanced MVVM Patterns in Jetpack Compose - Search, Pagination & Cross-Module Communication

Advanced MVVM Patterns in Jetpack Compose - Search, Pagination & Cross-Module Communication

By Sagar Maiyad  Nov 18, 2025

In Part 1, I covered the MVVM basics with a login screen. That's enough for simple apps, but production apps need more - search that doesn't hammer your API, lists that load more as you scroll, and modules that can communicate without creating a dependency nightmare.

This post covers the patterns I use when things get complicated.

The Search Problem Everyone Gets Wrong

Here's how I used to implement search:

// Don't do this
searchTextField.addTextChangedListener { text ->
    viewModel.search(text.toString())  // API call on every keystroke!
}

User types "android" - that's 7 API calls. Type fast enough and you'll hit rate limits, burn through user's data, and probably show results for "andr" before "android" finishes loading.

The fix is debouncing - wait until the user stops typing, then search. In Compose with Flow, it looks like this:

private val searchQuery = MutableStateFlow("")

init {
    viewModelScope.launch {
        searchQuery
            .debounce(300)  // Wait 300ms after last keystroke
            .distinctUntilChanged()  // Skip if same as previous
            .flatMapLatest { query ->
                // Cancel previous search, start new one
                flow { emit(query) }
            }
            .collect { query ->
                searchItems(query)
            }
    }
}

fun onSearchQueryChanged(query: String) {
    searchQuery.value = query
}

Let me break down what each operator does because I was confused by these for weeks:

debounce(300) - Waits 300 milliseconds of "silence" before emitting. If user types another character before 300ms, the timer resets. Only when they stop typing for 300ms does the value get through.

distinctUntilChanged() - If the debounced value is the same as the last one (user typed "test", deleted the "t", then added it back), don't emit. Prevents duplicate API calls.

flatMapLatest - This one's important. If a new search comes in while the old one is running, cancel the old one. Otherwise you might show results for "andr" after results for "android" if the shorter query's API call finishes last.

Managing State When Things Get Complicated

A login screen has simple state - loading or not, maybe an error message. A searchable, paginated list? Way more complex.

Here's the state class I use:

data class ItemListState(
    val items: List<Item> = emptyList(),
    val isLoading: Boolean = false,
    val isLoadingMore: Boolean = false,
    val error: String? = null,
    val searchQuery: String = "",
    val hasMore: Boolean = true,
    val currentPage: Int = 0
)

The key insight: isLoading and isLoadingMore are separate. isLoading shows a full-screen spinner (first load or new search). isLoadingMore shows a spinner at the bottom of the list (pagination). I mixed these up once and users saw a full-screen loader every time they scrolled. Not great.

Here's the ViewModel that ties it all together:

@HiltViewModel
class ItemListViewModel @Inject constructor(
    private val getItemsUseCase: GetItemsUseCase,
    private val connectivityManager: ConnectivityManager
) : ViewModel() {

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

    private val searchQuery = MutableStateFlow("")

    init {
        viewModelScope.launch {
            searchQuery
                .debounce(300)
                .distinctUntilChanged()
                .flatMapLatest { query -> flow { emit(query) } }
                .collect { query ->
                    _state.value = _state.value.copy(searchQuery = query)
                    searchItems(query)
                }
        }

        loadItems()
    }

    fun onSearchQueryChanged(query: String) {
        searchQuery.value = query
    }

    private fun loadItems(isLoadingMore: Boolean = false) {
        if (!isNetworkAvailable()) {
            _state.value = _state.value.copy(
                isLoading = false,
                isLoadingMore = false,
                error = "No internet connection"
            )
            return
        }

        viewModelScope.launch {
            // Different loading states for initial load vs pagination
            if (isLoadingMore) {
                _state.value = _state.value.copy(isLoadingMore = true)
            } else {
                _state.value = _state.value.copy(isLoading = true, error = null)
            }

            try {
                val currentPage = if (isLoadingMore) _state.value.currentPage + 1 else 0
                val result = getItemsUseCase(
                    query = _state.value.searchQuery,
                    page = currentPage,
                    limit = 20
                )

                if (result.isSuccess) {
                    val newItems = result.items ?: emptyList()

                    _state.value = _state.value.copy(
                        // Append for pagination, replace for new search
                        items = if (isLoadingMore) {
                            _state.value.items + newItems
                        } else {
                            newItems
                        },
                        isLoading = false,
                        isLoadingMore = false,
                        hasMore = newItems.size >= 20,  // Got full page = probably more
                        currentPage = currentPage,
                        error = null
                    )
                } else {
                    _state.value = _state.value.copy(
                        isLoading = false,
                        isLoadingMore = false,
                        error = result.message ?: "Failed to load items"
                    )
                }
            } catch (e: Exception) {
                _state.value = _state.value.copy(
                    isLoading = false,
                    isLoadingMore = false,
                    error = e.message ?: "An error occurred"
                )
            }
        }
    }

    private fun searchItems(query: String) {
        // New search = reset to page 0
        _state.value = _state.value.copy(currentPage = 0, items = emptyList())
        loadItems(isLoadingMore = false)
    }

    fun onLoadMore() {
        // Don't load more if already loading or no more items
        if (!_state.value.isLoadingMore && _state.value.hasMore) {
            loadItems(isLoadingMore = true)
        }
    }

    fun onRetry() {
        loadItems(isLoadingMore = false)
    }

    private fun isNetworkAvailable(): Boolean {
        val network = connectivityManager.activeNetwork ?: return false
        val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
        return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    }
}

The pagination logic is in that items = if (isLoadingMore) block. For pagination, append new items to the existing list. For a new search, replace the list entirely. I got this wrong in my first implementation and ended up with old search results mixed with new ones.

The UI Side

The UI needs to handle multiple states: loading, error, empty, and content. Here's how I structure it:

@Composable
fun ItemListScreen(
    viewModel: ItemListViewModel = hiltViewModel()
) {
    val state by viewModel.state.collectAsState()
    var searchText by remember { mutableStateOf("") }

    Column(modifier = Modifier.fillMaxSize()) {
        // Search bar
        OutlinedTextField(
            value = searchText,
            onValueChange = {
                searchText = it
                viewModel.onSearchQueryChanged(it)
            },
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            placeholder = { Text("Search items...") },
            leadingIcon = {
                Icon(Icons.Default.Search, contentDescription = "Search")
            },
            trailingIcon = {
                if (searchText.isNotEmpty()) {
                    IconButton(onClick = {
                        searchText = ""
                        viewModel.onSearchQueryChanged("")
                    }) {
                        Icon(Icons.Default.Clear, contentDescription = "Clear")
                    }
                }
            },
            singleLine = true
        )

        when {
            // Initial loading - show full screen spinner
            state.isLoading && state.items.isEmpty() -> {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator()
                }
            }

            // Error with no items - show error with retry
            state.error != null && state.items.isEmpty() -> {
                ErrorView(
                    message = state.error!!,
                    onRetry = { viewModel.onRetry() }
                )
            }

            // No items, not loading - empty state
            state.items.isEmpty() && !state.isLoading -> {
                EmptyView(searchQuery = state.searchQuery)
            }

            // We have items - show the list
            else -> {
                LazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    contentPadding = PaddingValues(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    items(state.items) { item ->
                        ItemCard(item = item)
                    }

                    // Load more at bottom
                    if (state.hasMore) {
                        item {
                            LoadMoreButton(
                                isLoading = state.isLoadingMore,
                                onClick = { viewModel.onLoadMore() }
                            )
                        }
                    }
                }
            }
        }
    }
}

Notice searchText is local state (mutableStateOf) while the actual search happens through the ViewModel. The text field needs to update immediately as the user types - if you debounced the text field itself, it would feel laggy. The debounce happens on the ViewModel side, between typing and API calls.

Cross-Module Communication (The Hard Problem)

This one took me a while to figure out. Say you have a Settings module and a Dashboard module. User changes theme in Settings, Dashboard needs to reload with the new theme. But Settings can't import Dashboard (circular dependency) and you don't want to pass callbacks everywhere.

I used to reach for EventBus libraries, but they use reflection and are hard to debug. Now I use a SharedFlow-based trigger system.

First, define what kinds of triggers can happen:

// In a :core module that everyone depends on
sealed class TriggerType {
    object ThemeChanged : TriggerType()
    object UserLoggedOut : TriggerType()
    data class DataUpdated(val dataId: String) : TriggerType()
    object RefreshDashboard : TriggerType()
}

Then create a manager that handles emitting and listening:

@Singleton
class TriggerManager @Inject constructor() {

    private val _triggers = MutableSharedFlow<TriggerType>(
        replay = 0,  // One-time events, no replay
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )

    val triggers: SharedFlow<TriggerType> = _triggers.shareIn(
        scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
        started = SharingStarted.WhileSubscribed(5000),
        replay = 0
    )

    suspend fun emitTrigger(trigger: TriggerType) {
        _triggers.emit(trigger)
    }
}

That WhileSubscribed(5000) is important - it keeps the flow active for 5 seconds after the last subscriber leaves. This handles screen rotation gracefully.

Settings emits:

@HiltViewModel
class SettingsViewModel @Inject constructor(
    private val triggerManager: TriggerManager
) : ViewModel() {

    fun onThemeChanged(isDarkMode: Boolean) {
        saveThemePreference(isDarkMode)

        viewModelScope.launch {
            triggerManager.emitTrigger(TriggerType.ThemeChanged)
        }
    }
}

Dashboard listens:

@HiltViewModel
class DashboardViewModel @Inject constructor(
    private val triggerManager: TriggerManager
) : ViewModel() {

    init {
        viewModelScope.launch {
            triggerManager.triggers.collect { trigger ->
                when (trigger) {
                    is TriggerType.ThemeChanged -> refreshDashboard()
                    is TriggerType.UserLoggedOut -> clearDashboard()
                    else -> { /* ignore */ }
                }
            }
        }
    }
}

No circular dependencies, type-safe (sealed class), and easy to test.

Flow Operators Cheat Sheet

I keep forgetting which operator does what. Here's my reference:

debounce(timeMillis) - Wait until the flow is "quiet" for the specified time. Use for search input.

distinctUntilChanged() - Skip if the new value equals the previous one. Prevents duplicate API calls.

flatMapLatest - When new value comes, cancel whatever the previous value started. Use when you only care about the latest result.

combine(flow1, flow2) { a, b -> } - Emit whenever either flow emits, combining latest values from both. Use when filtering by multiple criteria.

map { } - Transform each emitted value. Straightforward.

filter { } - Only emit values that pass the predicate. Use for things like "only search if query is 3+ characters."

Here's combine in action - filtering by both search query and category:

val searchQuery = MutableStateFlow("")
val selectedCategory = MutableStateFlow<Category?>(null)

combine(searchQuery, selectedCategory) { query, category ->
    Pair(query, category)
}.collect { (query, category) ->
    loadItems(query = query, category = category)
}

Error Handling That Doesn't Annoy Users

The worst is when an app shows "java.net.UnknownHostException: Unable to resolve host". Users don't know what that means. I translate errors to human-readable messages:

fun mapErrorToMessage(exception: Exception): String {
    return when (exception) {
        is UnknownHostException -> "No internet connection"
        is SocketTimeoutException -> "Request timed out. Try again."
        is HttpException -> when (exception.code()) {
            401 -> "Session expired. Please login again."
            404 -> "Item not found"
            in 500..599 -> "Server error. Please try again later."
            else -> "Something went wrong"
        }
        else -> "Something went wrong"
    }
}

And always check for network before making requests. Users in airplane mode deserve a clear message, not a 30-second wait followed by a cryptic error.

Retry with Exponential Backoff

Sometimes the server is just having a bad moment. Instead of failing immediately, retry a few times with increasing delays:

private suspend fun <T> retryWithBackoff(
    times: Int = 3,
    initialDelay: Long = 100,
    maxDelay: Long = 1000,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) {
        try {
            return block()
        } catch (e: Exception) {
            delay(currentDelay)
            currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
        }
    }
    return block()  // Last attempt, let it throw if it fails
}

First retry after 100ms, second after 200ms, third after 400ms. If all fail, then show the error. This handles temporary network blips without bothering the user.

Wrapping Up

These patterns handle most of what I encounter in production apps:

Search: Debounce user input, cancel old searches with flatMapLatest, show immediate UI feedback

Pagination: Separate loading states, append vs replace logic, track whether there are more items

Cross-module communication: SharedFlow-based triggers, no direct dependencies between feature modules

Error handling: Human-readable messages, network checks before requests, retry with backoff

The code samples here are simplified but the patterns are what I use in production. The complexity is in getting the details right - which is why I wrote this post. I kept making the same mistakes until I had these patterns down.


Previous: Part 1: MVVM Architecture with Authentication Flow

Android Jetpack Compose MVVM Kotlin Flow Pagination Architecture

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android Team Lead specializing in Kotlin, Jetpack Compose, Flutter, and Node.js development. Building practical Android apps with 2M+ downloads and sharing real-world development insights.

View All Posts →

Latest Post

Latest Tags