Unidirectional Data Flow in Jetpack Compose
Jetpack Compose pushes us toward unidirectional rendering.
State changes. Composables read the state. The UI is redrawn.
That part is built into the framework.
But an app can still have messy flow of control.
A composable can call repositories. A ViewModel can call use cases, own navigation, log analytics, map backend errors, and update state. A route can quietly become a place where every product decision lives.
So when we talk about unidirectional data flow in Compose, we should be precise.
Compose gives us a nice rendering model. We still have to design the feature flow.
#The Flow I Want
For a non-trivial feature, I like this direction:
UI state -> Composable
Composable intent -> Route or parent
Route or parent -> Use case
Use case -> Output
Output -> ViewModel
ViewModel -> UI state
Navigation can be separate:
ViewModel screen event -> Parent flow -> NavController
This keeps each piece small:
- the composable renders state and reports user intent
- the use case performs an application operation
- the ViewModel maps outcomes into UI state
- the parent flow decides navigation
That is the shape. The exact mechanics can vary.
#A Common Compose Shortcut
This is the version many apps grow into:
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val analytics: FirebaseAnalytics,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
private val _events = Channel<LoginEvent>()
val events = _events.receiveAsFlow()
fun submit(email: String, password: String) {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
try {
authRepository.login(email, password)
analytics.logEvent("login_succeeded", null)
_events.send(LoginEvent.NavigateHome)
} catch (error: Throwable) {
analytics.logEvent("login_failed", null)
_state.value = LoginUiState(
errorMessage = "Could not log in."
)
}
}
}
}
This is not automatically terrible.
For a small app, it may be perfectly understandable.
But the ViewModel is now the operation owner, the analytics owner, the error policy owner, the event owner, and the UI state owner. If the feature grows, every change lands in the same class.
That is where unidirectional rendering is not enough.
We need unidirectional responsibilities too.
#Name The State
Start with what the screen can render.
data class LoginUiState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null
)
This state is a UI contract. A composable should be able to render from it without reaching into repositories, use cases, or services.
@Composable
fun LoginScreen(
state: LoginUiState,
onIntent: (LoginIntent) -> Unit
) {
Column {
TextField(
value = state.email,
onValueChange = {
onIntent(LoginIntent.EmailChanged(it))
}
)
TextField(
value = state.password,
onValueChange = {
onIntent(LoginIntent.PasswordChanged(it))
}
)
if (state.errorMessage != null) {
Text(state.errorMessage)
}
Button(
enabled = !state.isLoading,
onClick = { onIntent(LoginIntent.Submit) }
) {
Text("Log in")
}
}
}
The composable does not know what login means. It only reports what the user did.
#Name The Intents
UI intents should describe interaction, not implementation.
sealed interface LoginIntent {
data class EmailChanged(val value: String) : LoginIntent
data class PasswordChanged(val value: String) : LoginIntent
data object Submit : LoginIntent
}
Submit does not say "call Retrofit" or "navigate home."
It says the user submitted the form.
That distinction gives the parent or route room to decide how the intent is handled.
#Name The Use Case Outcomes
The use case should speak in application outcomes.
interface LoginOutput {
fun loginDidStart()
fun loginDidSucceed()
fun loginDidFail(reason: LoginFailure)
}
class LoginUseCase @Inject constructor(
private val authenticator: Authenticator,
private val sessionStore: SessionStore
) {
suspend fun execute(
email: String,
password: String,
output: LoginOutput
) {
output.loginDidStart()
try {
val session = authenticator.login(email, password)
sessionStore.save(session)
output.loginDidSucceed()
} catch (error: InvalidCredentials) {
output.loginDidFail(LoginFailure.InvalidCredentials)
} catch (error: Throwable) {
output.loginDidFail(LoginFailure.Unknown)
}
}
}
The use case does not return a backend response and force presentation code to infer what happened.
It reports the meaningful outcomes of the operation.
That makes the flow easier to test, decorate, and change.
#Let The ViewModel Map Outcomes
The ViewModel can own state and map use case outcomes into that state.
sealed interface LoginScreenEvent {
data object LoginCompleted : LoginScreenEvent
}
class LoginViewModel : ViewModel(), LoginOutput {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
private val _events = Channel<LoginScreenEvent>()
val events: Flow<LoginScreenEvent> = _events.receiveAsFlow()
fun updateEmail(value: String) {
_state.value = _state.value.copy(email = value)
}
fun updatePassword(value: String) {
_state.value = _state.value.copy(password = value)
}
override fun loginDidStart() {
_state.value = _state.value.copy(
isLoading = true,
errorMessage = null
)
}
override fun loginDidSucceed() {
_state.value = _state.value.copy(isLoading = false)
_events.trySend(LoginScreenEvent.LoginCompleted)
}
override fun loginDidFail(reason: LoginFailure) {
_state.value = _state.value.copy(
isLoading = false,
errorMessage = when (reason) {
LoginFailure.InvalidCredentials -> "Check your email and password."
LoginFailure.Unknown -> "Could not log in."
}
)
}
}
The ViewModel is still useful. It owns form state. It maps login outcomes. It emits a small screen event.
But it does not know how authentication works, where sessions are stored, or where the app navigates after login.
#Let The Route Connect The Pieces
The route can connect Compose state, user intents, and the composed action.
@Composable
fun LoginRoute(
viewModel: LoginViewModel,
login: LoginUseCase,
onLoginCompleted: () -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(viewModel) {
viewModel.events.collect { event ->
when (event) {
LoginScreenEvent.LoginCompleted -> onLoginCompleted()
}
}
}
LoginScreen(
state = state,
onIntent = { intent ->
when (intent) {
is LoginIntent.EmailChanged -> {
viewModel.updateEmail(intent.value)
}
is LoginIntent.PasswordChanged -> {
viewModel.updatePassword(intent.value)
}
LoginIntent.Submit -> {
coroutineScope.launch {
login.execute(
email = state.email,
password = state.password,
output = viewModel
)
}
}
}
}
)
}
This route is allowed to connect things. That is its job.
The important part is that the decisions stay small:
- input changes update ViewModel state
- submit triggers the login use case
- the use case reports output
- the ViewModel maps output back into state or a screen event
- the parent handles the screen event
That is unidirectional flow with explicit responsibilities.
#Keep Navigation In The Parent Flow
The parent flow can decide what login completion means.
@Composable
fun AppRoot(
loginUseCase: LoginUseCase,
loginViewModel: LoginViewModel = remember { LoginViewModel() }
) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "login"
) {
composable("login") {
LoginRoute(
viewModel = loginViewModel,
login = loginUseCase,
onLoginCompleted = {
navController.navigate("home")
}
)
}
composable("home") {
HomeScreen()
}
}
}
The login feature does not know that completion means home.
In a different parent, the same login event could dismiss a dialog, return to checkout, or continue onboarding.
That is why a screen event is not the same thing as a navigation command.
#One-Off Events Are Not UI State
Compose teams often debate whether navigation, snackbars, and one-off messages should be part of state.
My default is:
- durable things the screen renders belong in
UiState - one-off things the parent reacts to can be events
Loading, form values, product rows, and error text are state.
Navigation completion is an event.
private val _events = Channel<LoginScreenEvent>()
val events: Flow<LoginScreenEvent> = _events.receiveAsFlow()
You can use different mechanisms: Channel, SharedFlow, or another event abstraction. The tool matters less than the meaning.
The important part is not to pretend a one-time navigation effect is the same kind of thing as a text field value.
#Where Analytics Fits
Analytics should not automatically go in the ViewModel.
If analytics belongs to the login operation, decorate the output:
class AnalyticsLoginOutput(
private val decoratee: LoginOutput,
private val analytics: Analytics
) : LoginOutput {
override fun loginDidStart() {
decoratee.loginDidStart()
}
override fun loginDidSucceed() {
analytics.track("login_succeeded")
decoratee.loginDidSucceed()
}
override fun loginDidFail(reason: LoginFailure) {
analytics.track("login_failed")
decoratee.loginDidFail(reason)
}
}
Then composition decides whether the ViewModel is decorated:
val output = AnalyticsLoginOutput(
decoratee = viewModel,
analytics = analytics
)
login.execute(
email = state.email,
password = state.password,
output = output
)
The ViewModel keeps mapping state. Analytics is added from the outside.
That is the Open-Closed Principle showing up in normal Android code.
#This Is Not About Building A Giant Reducer
Some teams hear "unidirectional data flow" and jump straight to a single reducer for the whole feature.
That can be useful.
It can also become a different kind of god object.
The goal is not to force every feature into one MVI shape. The goal is to make the direction of communication obvious.
If a reducer helps, use it.
If a ViewModel plus use case outputs is clearer, use that.
What matters is avoiding this:
Composable knows repository
ViewModel knows NavController
Use case returns backend DTO
Analytics is mixed into state mapping
Tests mock every internal call
That is not a clean flow. It is a knot with Compose syntax.
#When To Keep It Simpler
For small screens, it is okay for a ViewModel to call a use case directly.
fun submit() {
viewModelScope.launch {
login.execute(email, password)
}
}
You do not need a factory, route model, output decorator, and custom event pipeline for every button.
The split starts to pay for itself when:
- the use case has multiple meaningful outcomes
- navigation changes by entry point
- analytics or logging should be added without editing the ViewModel
- previews need cheap fake behavior
- tests are mostly Mockito interaction scripts
- the ViewModel is becoming the place where every decision goes
Architecture should respond to pressure.
#What You Should Remember
Compose gives you a clean rendering model. It does not automatically give you clean application flow.
For larger features, keep the direction explicit:
Composable intent -> route action -> use case -> output -> ViewModel state -> Composable
Use screen events for navigation and parent-level decisions.
Let the ViewModel prepare state. Let the use case perform the operation. Let the parent flow own the journey.