A product catalog with faceted search is one of the most complex UI patterns in e-commerce: it combines real-time search, multi-dimensional filtering, and paginated results that all change together.
Data Model
data class SearchState(
val query: String = "",
val filters: SearchFilters = SearchFilters(),
val sortOrder: SortOrder = SortOrder.RELEVANCE,
val results: List<Product> = emptyList(),
val facets: List<Facet> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val totalResults: Int = 0
)
data class SearchFilters(
val categories: Set<String> = emptySet(),
val priceRange: ClosedRange<Int>? = null,
val brands: Set<String> = emptySet(),
val rating: Float? = null,
val inStockOnly: Boolean = false
)
data class Facet(
val name: String, // "Brand", "Category", "Price Range"
val type: FacetType,
val values: List<FacetValue>
)
data class FacetValue(
val id: String,
val label: String,
val count: Int, // number of results if this value is selected
val isSelected: Boolean
)
enum class FacetType { CHECKBOX, RANGE, RADIO }
enum class SortOrder { RELEVANCE, PRICE_ASC, PRICE_DESC, RATING, NEWEST }
ViewModel: Debounced Search
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: CatalogRepository
) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
private val searchTrigger = MutableSharedFlow<Unit>(replay = 1)
init {
viewModelScope.launch {
// Combine query + filter changes, debounce, then search
combine(
_state.map { it.query }.distinctUntilChanged(),
_state.map { it.filters }.distinctUntilChanged(),
_state.map { it.sortOrder }.distinctUntilChanged()
) { _, _, _ -> Unit }
.debounce(300) // wait 300ms after last change before searching
.collect { performSearch() }
}
}
fun onQueryChanged(query: String) {
_state.update { it.copy(query = query) }
}
fun onFilterChanged(filters: SearchFilters) {
_state.update { it.copy(filters = filters) }
}
fun onSortChanged(sort: SortOrder) {
_state.update { it.copy(sortOrder = sort) }
}
fun toggleCategoryFilter(category: String) {
val current = _state.value.filters.categories
val updated = if (category in current) current - category else current + category
onFilterChanged(_state.value.filters.copy(categories = updated))
}
private suspend fun performSearch() {
_state.update { it.copy(isLoading = true, error = null) }
val s = _state.value
repository.search(
query = s.query,
filters = s.filters,
sortOrder = s.sortOrder
).onSuccess { response ->
_state.update { it.copy(
results = response.products,
facets = response.facets,
totalResults = response.totalCount,
isLoading = false
) }
}.onFailure { error ->
_state.update { it.copy(error = error.message, isLoading = false) }
}
}
}
Facet Filter UI
@Composable
fun FacetPanel(facets: List<Facet>, onFilterChanged: (SearchFilters) -> Unit) {
LazyColumn {
items(facets) { facet ->
when (facet.type) {
FacetType.CHECKBOX -> CheckboxFacet(facet, onFilterChanged)
FacetType.RANGE -> RangeFacet(facet, onFilterChanged)
FacetType.RADIO -> RadioFacet(facet, onFilterChanged)
}
}
}
}
@Composable
fun CheckboxFacet(facet: Facet, onChanged: (SearchFilters) -> Unit) {
Column {
Text(facet.name, style = MaterialTheme.typography.titleSmall)
facet.values.forEach { value ->
Row(
modifier = Modifier.fillMaxWidth().clickable { /* toggle */ }.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = value.isSelected,
onCheckedChange = { /* toggle value */ }
)
Text("${value.label} (${value.count})", modifier = Modifier.weight(1f))
}
}
}
}
Search with Paging 3
For large catalogs, paginate search results:
class CatalogPagingSource(
private val api: CatalogApi,
private val query: String,
private val filters: SearchFilters,
private val sort: SortOrder
) : PagingSource<Int, Product>() {
override fun getRefreshKey(state: PagingState<Int, Product>): Int? = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> = try {
val page = params.key ?: 1
val response = api.search(
query = query,
page = page,
pageSize = params.loadSize,
filters = filters.toApiParams(),
sort = sort.name
)
LoadResult.Page(
data = response.products,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.hasNextPage) page + 1 else null
)
} catch (e: IOException) {
LoadResult.Error(e)
}
}
// Repository creates a new PagingSource when search params change
fun searchPaged(query: String, filters: SearchFilters, sort: SortOrder): Flow<PagingData<Product>> =
Pager(PagingConfig(pageSize = 20)) {
CatalogPagingSource(api, query, filters, sort)
}.flow
Key Takeaways
| Pattern | Rule |
|---|---|
| Debounce query | 300ms debounce prevents spamming the API on every keystroke |
| Facets from server | Return facet counts from the same search endpoint; don't compute client-side |
| Clear filters = new search | Changing any filter triggers a fresh search with page = 1 |
| Paging for results | Use Paging 3 for large catalogs; room for > 1000 products |
| Filter chips | Show active filters as dismissible chips above results |
distinctUntilChanged | Prevent redundant searches when filters haven't actually changed |