androidengineers.Book a session

Architecture Patterns

Unidirectional Data Flow & State Modeling

article25 minMedium

What Is Unidirectional Data Flow?

In Unidirectional Data Flow (UDF), data moves in one direction:

User Action → ViewModel processes → New State → UI renders

The UI never mutates state directly. Instead it dispatches an action, the ViewModel transforms the current state into a new state, and the UI re-renders. This makes the system predictable: given a state, the UI is deterministic. Given an action and the current state, the next state is deterministic.


Modeling UI State with Sealed Classes

A common mistake is using multiple boolean flags:

// Fragile: flags can contradict each other
var isLoading: Boolean = false
var hasError: Boolean = false
var data: List<Item>? = null
// isLoading=true AND hasError=true is an impossible state!

Use a sealed class or sealed interface to make impossible states impossible:

sealed interface UiState<out T> {
    object Loading : UiState<Nothing>
    data class Success<T>(val data: T) : UiState<T>
    data class Error(val message: String, val retryable: Boolean = true) : UiState<Nothing>
}

For screens with multiple concurrent concerns, use a data class combining independent flags:

data class SearchUiState(
    val query: String = "",
    val results: List<SearchResult> = emptyList(),
    val isSearching: Boolean = false,
    val isLoadingMore: Boolean = false,
    val error: String? = null,
    val endReached: Boolean = false
)

The key principle: every combination of fields should be a valid state.


Sealed UiAction and UiEvent

UiAction = what the user did. The ViewModel receives these.

sealed interface SearchAction {
    data class QueryChanged(val query: String) : SearchAction
    object Search : SearchAction
    object LoadMore : SearchAction
    object Retry : SearchAction
    data class ResultClicked(val resultId: String) : SearchAction
}

UiEvent (side effects) = one-shot events the UI should handle once, like navigation or Snackbars. These must NOT go into StateFlow because new collectors would re-trigger them.

sealed interface SearchEvent {
    data class NavigateToDetail(val itemId: String) : SearchEvent
    data class ShowSnackbar(val message: String) : SearchEvent
    object ClearFocus : SearchEvent
}

Practical Search Screen Example

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val searchRepository: SearchRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(SearchUiState())
    val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()

    // Channel for one-shot effects — never misses events
    private val _events = Channel<SearchEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()

    private var searchJob: Job? = null
    private var page = 0

    fun onAction(action: SearchAction) {
        when (action) {
            is SearchAction.QueryChanged -> onQueryChanged(action.query)
            SearchAction.Search -> triggerSearch()
            SearchAction.LoadMore -> loadMore()
            SearchAction.Retry -> retry()
            is SearchAction.ResultClicked -> onResultClicked(action.resultId)
        }
    }

    private fun onQueryChanged(query: String) {
        _uiState.update { it.copy(query = query, error = null) }
        // Debounce search as user types
        searchJob?.cancel()
        if (query.length >= 3) {
            searchJob = viewModelScope.launch {
                delay(300)
                performSearch(query, reset = true)
            }
        }
    }

    private fun triggerSearch() {
        val query = _uiState.value.query
        if (query.isBlank()) return
        viewModelScope.launch {
            performSearch(query, reset = true)
            _events.send(SearchEvent.ClearFocus)
        }
    }

    private fun loadMore() {
        val state = _uiState.value
        if (state.isLoadingMore || state.endReached || state.isSearching) return
        viewModelScope.launch {
            performSearch(_uiState.value.query, reset = false)
        }
    }

    private fun retry() {
        val query = _uiState.value.query
        viewModelScope.launch { performSearch(query, reset = true) }
    }

    private suspend fun performSearch(query: String, reset: Boolean) {
        if (reset) {
            page = 0
            _uiState.update { it.copy(isSearching = true, error = null, results = emptyList(), endReached = false) }
        } else {
            _uiState.update { it.copy(isLoadingMore = true) }
        }

        searchRepository.search(query = query, page = page)
            .onSuccess { newResults ->
                page++
                _uiState.update {
                    it.copy(
                        isSearching = false,
                        isLoadingMore = false,
                        results = if (reset) newResults else it.results + newResults,
                        endReached = newResults.isEmpty()
                    )
                }
            }
            .onFailure { error ->
                _uiState.update {
                    it.copy(
                        isSearching = false,
                        isLoadingMore = false,
                        error = error.message ?: "Search failed"
                    )
                }
                _events.send(SearchEvent.ShowSnackbar("Search failed. Tap to retry."))
            }
    }

    private fun onResultClicked(resultId: String) {
        viewModelScope.launch {
            _events.send(SearchEvent.NavigateToDetail(resultId))
        }
    }
}

Connecting the UI

@AndroidEntryPoint
class SearchFragment : Fragment(R.layout.fragment_search) {

    private val viewModel: SearchViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Wire up inputs
        binding.searchBar.addTextChangedListener { text ->
            viewModel.onAction(SearchAction.QueryChanged(text.toString()))
        }
        binding.searchBar.setOnEditorActionListener { _, _, _ ->
            viewModel.onAction(SearchAction.Search)
            true
        }
        binding.btnRetry.setOnClickListener {
            viewModel.onAction(SearchAction.Retry)
        }

        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // State: render the whole screen from a single source of truth
                launch {
                    viewModel.uiState.collect { state -> render(state) }
                }
                // Events: handle one-shot effects
                launch {
                    viewModel.events.collect { event ->
                        when (event) {
                            is SearchEvent.NavigateToDetail ->
                                findNavController().navigate(
                                    SearchFragmentDirections.actionSearchToDetail(event.itemId)
                                )
                            is SearchEvent.ShowSnackbar ->
                                Snackbar.make(view, event.message, Snackbar.LENGTH_LONG).show()
                            SearchEvent.ClearFocus ->
                                binding.searchBar.clearFocus()
                        }
                    }
                }
            }
        }
    }

    private fun render(state: SearchUiState) {
        binding.progressBar.isVisible = state.isSearching
        binding.loadingMoreIndicator.isVisible = state.isLoadingMore
        binding.errorGroup.isVisible = state.error != null && !state.isSearching
        binding.errorText.text = state.error
        adapter.submitList(state.results)
        binding.emptyState.isVisible =
            !state.isSearching && state.results.isEmpty() && state.error == null && state.query.isNotBlank()
    }
}

Reducing Actions to State

The reducer pattern makes state transitions explicit and testable without the UI:

// Pure function — easy to unit test
fun reduce(current: SearchUiState, action: SearchAction): SearchUiState = when (action) {
    is SearchAction.QueryChanged -> current.copy(query = action.query, error = null)
    SearchAction.Search -> current.copy(isSearching = true, error = null)
    SearchAction.Retry -> current.copy(isSearching = true, error = null, results = emptyList())
    else -> current
}

// Unit test
@Test
fun `query changed clears error`() {
    val initial = SearchUiState(error = "Previous error")
    val next = reduce(initial, SearchAction.QueryChanged("new query"))
    assertNull(next.error)
    assertEquals("new query", next.query)
}

Side Effects: Channel vs SharedFlow

ChannelSharedFlow
Delivery guaranteeExactly once (buffered)Broadcast — multiple collectors each receive
Missed eventsBuffered until collectedConfigurable replay
Use forNavigation, Snackbar (one-shot)Analytics events, multi-observer scenarios
Default recommendationEvents/side effectsState streams

Use Channel for side effects: it won't re-deliver to late collectors:

private val _events = Channel<SideEffect>(Channel.BUFFERED)
val events = _events.receiveAsFlow() // converts to Flow for collection

Key Takeaways

ConceptSummary
UDFData flows one way: Action → State → UI
Sealed UiStateMakes impossible states unrepresentable
UiActionTyped, exhaustive list of user interactions
UiEvent (side effects)One-shot events via Channel, not StateFlow
ReducerPure function: (State, Action) → State; trivially testable
update {}Atomic state update on MutableStateFlow

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Unidirectional Data Flow & State Modeling | Android System Design | Android Engineers