Skip to main content

Overview

TecMeli uses a unidirectional data flow architecture powered by Kotlin’s StateFlow and a custom UiState sealed class. This approach ensures predictable, reactive UI updates that are easy to test and reason about.

Architecture Pattern

Data flows in one direction: from user actions through ViewModels to repositories, and back to the UI via StateFlow. This makes the state predictable and easy to debug.

UiState Sealed Class

The UiState sealed class represents all possible states of a screen:
sealed class UiState<out T> {
    /** Indicates that an asynchronous operation is in progress */
    object Loading : UiState<Nothing>()

    /**
     * Represents a successful operation with data
     * @property data The content obtained after the successful operation
     */
    data class Success<T>(val data: T) : UiState<T>()

    /**
     * Represents a failed operation
     * @property message User-friendly error description
     * @property exception Optional technical cause of the error
     */
    data class Error(
        val message: String, 
        val exception: Throwable? = null
    ) : UiState<Nothing>()

    /** Indicates the operation completed successfully but returned no results */
    object Empty : UiState<Nothing>()
}
See core/util/UiState.kt:11

Loading

Displayed while fetching data from the API

Success

Contains the actual data to display

Error

Holds user-friendly error messages

Empty

Represents valid but empty results

StateFlow in ViewModels

ViewModels expose state through StateFlow, which is a hot, state-holding observable:
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getProductsUseCase: GetProductsUseCase
) : ViewModel() {

    // Private mutable state - only the ViewModel can modify it
    private val _uiState = MutableStateFlow<UiState<List<Product>>>(UiState.Empty)
    
    // Public immutable state - the UI observes this
    val uiState: StateFlow<UiState<List<Product>>> = _uiState.asStateFlow()

    fun searchProducts(query: String) {
        if (query.isBlank()) {
            _uiState.value = UiState.Empty
            return
        }

        viewModelScope.launch {
            _uiState.value = UiState.Loading
            
            getProductsUseCase(query)
                .onSuccess { products ->
                    _uiState.value = if (products.isEmpty()) {
                        UiState.Empty
                    } else {
                        UiState.Success(products)
                    }
                }
                .onFailure { error ->
                    _uiState.value = UiState.Error(
                        message = error.localizedMessage ?: "Ocurrió un error inesperado",
                        exception = error
                    )
                }
        }
    }
}
See ui/screen/home/HomeViewModel.kt:24
1

Initial State

The ViewModel starts with UiState.Empty to indicate no data is available yet.
2

Loading State

When a search begins, the state transitions to UiState.Loading.
3

Success or Error

Based on the API result, the state becomes either Success with data or Error with a message.
4

UI Reaction

The Composable UI automatically recomposes when the state changes.

Observing State in Compose

Composables collect the StateFlow and react to state changes:
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    Column(modifier = Modifier.fillMaxSize()) {
        SearchBar(
            onSearch = { query -> viewModel.searchProducts(query) }
        )
        
        when (uiState) {
            is UiState.Loading -> {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.CenterHorizontally)
                )
            }
            
            is UiState.Success -> {
                val products = (uiState as UiState.Success).data
                LazyColumn {
                    items(products) { product ->
                        ProductCard(product)
                    }
                }
            }
            
            is UiState.Error -> {
                val error = (uiState as UiState.Error).message
                ErrorMessage(
                    message = error,
                    onRetry = { /* retry logic */ }
                )
            }
            
            is UiState.Empty -> {
                EmptyState(message = "Busca productos de Mercado Libre")
            }
        }
    }
}
Always use collectAsStateWithLifecycle() instead of collectAsState() to ensure the flow collection respects the lifecycle and stops when the app is in the background.

State Transitions

Real-World Example: Product Detail

Here’s another practical example from the Product Detail screen:
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
    private val getProductDetailUseCase: GetProductDetailUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState<ProductDetail>>(UiState.Empty)
    val uiState: StateFlow<UiState<ProductDetail>> = _uiState.asStateFlow()

    fun getProductDetail(id: String) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            
            getProductDetailUseCase(id)
                .onSuccess { detail ->
                    _uiState.value = UiState.Success(detail)
                }
                .onFailure { error ->
                    _uiState.value = UiState.Error(
                        message = error.localizedMessage ?: "Error al cargar el detalle",
                        exception = error
                    )
                }
        }
    }
}
See ui/screen/product_detail/ProductDetailViewModel.kt:24

Benefits of This Pattern

Sealed classes ensure you handle all possible states at compile-time. The Kotlin compiler will warn you if you forget to handle a state in your when expression.
State can only be modified by the ViewModel, and changes flow unidirectionally to the UI. No surprises or race conditions.
You can easily test state transitions by asserting the values emitted by the StateFlow:
@Test
fun `searchProducts emits Loading then Success`() = runTest {
    val viewModel = HomeViewModel(fakeUseCase)
    val states = mutableListOf<UiState<List<Product>>>()
    
    viewModel.uiState.onEach { states.add(it) }.launchIn(this)
    viewModel.searchProducts("phone")
    
    assertEquals(UiState.Loading, states[1])
    assertTrue(states[2] is UiState.Success)
}
Jetpack Compose automatically recomposes UI when StateFlow values change, ensuring your UI is always in sync with your data.

Best Practices

Expose Immutable State

Always expose StateFlow (read-only) and keep MutableStateFlow private

Single Source of Truth

Each screen should have one StateFlow that represents its entire state

Handle All States

Always use exhaustive when expressions to handle all UiState variants

Meaningful Messages

Provide user-friendly error messages in UiState.Error

Common Patterns

Loading State for Partial Updates

For screens that need to show existing data while loading new data:
data class ProductListState(
    val products: List<Product> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

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

fun loadMore() {
    viewModelScope.launch {
        _state.value = _state.value.copy(isLoading = true)
        // Load more products...
        _state.value = _state.value.copy(
            products = _state.value.products + newProducts,
            isLoading = false
        )
    }
}

Combining Multiple States

For screens with multiple independent data sources:
data class DashboardState(
    val user: UiState<User> = UiState.Empty,
    val products: UiState<List<Product>> = UiState.Empty,
    val orders: UiState<List<Order>> = UiState.Empty
)

Next Steps

Error Handling

Learn how to map errors into user-friendly messages

Network Layer

Understand how data flows from API to UiState

Build docs developers (and LLMs) love