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
Channel | SharedFlow | |
|---|---|---|
| Delivery guarantee | Exactly once (buffered) | Broadcast — multiple collectors each receive |
| Missed events | Buffered until collected | Configurable replay |
| Use for | Navigation, Snackbar (one-shot) | Analytics events, multi-observer scenarios |
| Default recommendation | Events/side effects | State 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
| Concept | Summary |
|---|---|
| UDF | Data flows one way: Action → State → UI |
| Sealed UiState | Makes impossible states unrepresentable |
| UiAction | Typed, exhaustive list of user interactions |
| UiEvent (side effects) | One-shot events via Channel, not StateFlow |
| Reducer | Pure function: (State, Action) → State; trivially testable |
update {} | Atomic state update on MutableStateFlow |