Objective
Implement a Profile Edit screen using Unidirectional Data Flow (UDF). By the end you will have:
- A typed
UiStatedata class - A sealed
UiActionclass for all user interactions - A ViewModel that acts as a pure reducer of actions to state
- A
SharedFlowfor one-time navigation side effects - A Compose UI that sends actions and renders state
- Unit tests for the reducer logic
What Is UDF?
Unidirectional Data Flow means:
- State flows down — the ViewModel holds state, the UI reads it
- Events flow up — the UI sends actions to the ViewModel, never mutates state directly
- Reducer processes events — the ViewModel produces a new state from (current state, action)
UI → Action → ViewModel (reducer) → new State → UI
↓
Side Effect (navigation, toast)
Step 1: Define the UiState
The UiState represents every piece of information the screen needs to render. Keep it a plain data class — no logic, no dependencies.
data class ProfileEditUiState(
val name: String = "",
val email: String = "",
val bio: String = "",
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val nameError: String? = null,
val emailError: String? = null,
val isDirty: Boolean = false // true if user has made changes
) {
// Derived — do not store separately
val canSave: Boolean
get() = !isSaving && isDirty && nameError == null && emailError == null
&& name.isNotBlank() && email.isNotBlank()
}
Step 2: Define the UiAction Sealed Class
Every user interaction becomes an action. The sealed class makes all possible interactions explicit and exhaustively handleable.
sealed class ProfileEditAction {
data class NameChanged(val name: String) : ProfileEditAction()
data class EmailChanged(val email: String) : ProfileEditAction()
data class BioChanged(val bio: String) : ProfileEditAction()
object SaveClicked : ProfileEditAction()
object DiscardClicked : ProfileEditAction()
object RetryLoad : ProfileEditAction()
}
Step 3: Implement the ViewModel
The ViewModel acts as the reducer: it receives actions and produces new state. The when (action) block should read like a specification.
@HiltViewModel
class ProfileEditViewModel @Inject constructor(
private val userRepository: UserRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileEditUiState(isLoading = true))
val uiState: StateFlow<ProfileEditUiState> = _uiState.asStateFlow()
// One-time side effects — navigation, snackbars
private val _effect = MutableSharedFlow<ProfileEditEffect>(extraBufferCapacity = 1)
val effect: SharedFlow<ProfileEditEffect> = _effect.asSharedFlow()
// Original values for dirty tracking
private var originalName = ""
private var originalEmail = ""
private var originalBio = ""
init {
loadProfile()
}
fun onAction(action: ProfileEditAction) {
when (action) {
is ProfileEditAction.NameChanged -> handleNameChanged(action.name)
is ProfileEditAction.EmailChanged -> handleEmailChanged(action.email)
is ProfileEditAction.BioChanged -> handleBioChanged(action.bio)
is ProfileEditAction.SaveClicked -> handleSave()
is ProfileEditAction.DiscardClicked -> handleDiscard()
is ProfileEditAction.RetryLoad -> loadProfile()
}
}
private fun loadProfile() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val user = userRepository.getCurrentUser()
originalName = user.name
originalEmail = user.email
originalBio = user.bio
_uiState.update {
ProfileEditUiState(
name = user.name,
email = user.email,
bio = user.bio,
isLoading = false
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false)
}
_effect.emit(ProfileEditEffect.ShowError("Failed to load profile"))
}
}
}
private fun handleNameChanged(name: String) {
_uiState.update { current ->
current.copy(
name = name,
nameError = validateName(name),
isDirty = isDirty(name, current.email, current.bio)
)
}
}
private fun handleEmailChanged(email: String) {
_uiState.update { current ->
current.copy(
email = email,
emailError = validateEmail(email),
isDirty = isDirty(current.name, email, current.bio)
)
}
}
private fun handleBioChanged(bio: String) {
_uiState.update { current ->
current.copy(
bio = bio,
isDirty = isDirty(current.name, current.email, bio)
)
}
}
private fun handleSave() {
val current = _uiState.value
if (!current.canSave) return
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true) }
try {
userRepository.updateProfile(
name = current.name,
email = current.email,
bio = current.bio
)
_effect.emit(ProfileEditEffect.NavigateBack(savedSuccessfully = true))
} catch (e: Exception) {
_uiState.update { it.copy(isSaving = false) }
_effect.emit(ProfileEditEffect.ShowError("Save failed: ${e.message}"))
}
}
}
private fun handleDiscard() {
if (_uiState.value.isDirty) {
_effect.emit(ProfileEditEffect.ShowDiscardConfirmation)
} else {
viewModelScope.launch {
_effect.emit(ProfileEditEffect.NavigateBack(savedSuccessfully = false))
}
}
}
private fun validateName(name: String): String? = when {
name.isBlank() -> "Name cannot be empty"
name.length < 2 -> "Name must be at least 2 characters"
name.length > 50 -> "Name must be 50 characters or fewer"
else -> null
}
private fun validateEmail(email: String): String? = when {
email.isBlank() -> "Email cannot be empty"
!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() -> "Invalid email address"
else -> null
}
private fun isDirty(name: String, email: String, bio: String): Boolean =
name != originalName || email != originalEmail || bio != originalBio
}
Step 4: Define Side Effects
Side effects are one-time events the UI must handle — navigation, dialogs, snackbars. They are distinct from state because they should not be re-delivered on recomposition.
sealed class ProfileEditEffect {
data class NavigateBack(val savedSuccessfully: Boolean) : ProfileEditEffect()
data class ShowError(val message: String) : ProfileEditEffect()
object ShowDiscardConfirmation : ProfileEditEffect()
}
Step 5: Build the Compose UI
The UI is a pure function of state. It sends actions and renders state — nothing else.
@Composable
fun ProfileEditScreen(
onNavigateBack: (Boolean) -> Unit,
viewModel: ProfileEditViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var showDiscardDialog by remember { mutableStateOf(false) }
// Collect one-time effects
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is ProfileEditEffect.NavigateBack -> {
onNavigateBack(effect.savedSuccessfully)
}
is ProfileEditEffect.ShowError -> {
snackbarHostState.showSnackbar(effect.message)
}
is ProfileEditEffect.ShowDiscardConfirmation -> {
showDiscardDialog = true
}
}
}
}
if (showDiscardDialog) {
AlertDialog(
onDismissRequest = { showDiscardDialog = false },
title = { Text("Discard changes?") },
text = { Text("Your unsaved changes will be lost.") },
confirmButton = {
TextButton(onClick = {
showDiscardDialog = false
viewModel.onAction(ProfileEditAction.DiscardClicked)
}) { Text("Discard") }
},
dismissButton = {
TextButton(onClick = { showDiscardDialog = false }) { Text("Keep editing") }
}
)
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Edit Profile") },
navigationIcon = {
IconButton(onClick = { viewModel.onAction(ProfileEditAction.DiscardClicked) }) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
},
actions = {
TextButton(
onClick = { viewModel.onAction(ProfileEditAction.SaveClicked) },
enabled = uiState.canSave
) {
if (uiState.isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Save")
}
}
}
)
}
) { padding ->
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
ProfileEditContent(
uiState = uiState,
onAction = viewModel::onAction,
modifier = Modifier.padding(padding)
)
}
}
}
@Composable
fun ProfileEditContent(
uiState: ProfileEditUiState,
onAction: (ProfileEditAction) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = uiState.name,
onValueChange = { onAction(ProfileEditAction.NameChanged(it)) },
label = { Text("Name") },
isError = uiState.nameError != null,
supportingText = uiState.nameError?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = uiState.email,
onValueChange = { onAction(ProfileEditAction.EmailChanged(it)) },
label = { Text("Email") },
isError = uiState.emailError != null,
supportingText = uiState.emailError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = uiState.bio,
onValueChange = { onAction(ProfileEditAction.BioChanged(it)) },
label = { Text("Bio") },
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth()
)
}
}
Step 6: Unit Test the Reducer
The ViewModel is now trivially testable because it is a pure reducer.
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileEditViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val fakeRepository = FakeUserRepository()
private lateinit var viewModel: ProfileEditViewModel
@Before
fun setup() {
fakeRepository.setUser(User("Alice", "alice@example.com", "Android developer"))
viewModel = ProfileEditViewModel(fakeRepository, SavedStateHandle())
}
@Test
fun `initial load populates state`() = runTest {
advanceUntilIdle()
val state = viewModel.uiState.value
assertThat(state.isLoading).isFalse()
assertThat(state.name).isEqualTo("Alice")
assertThat(state.email).isEqualTo("alice@example.com")
}
@Test
fun `NameChanged with blank name sets nameError`() = runTest {
advanceUntilIdle()
viewModel.onAction(ProfileEditAction.NameChanged(""))
val state = viewModel.uiState.value
assertThat(state.nameError).isNotNull()
assertThat(state.canSave).isFalse()
}
@Test
fun `NameChanged with valid name clears nameError and sets isDirty`() = runTest {
advanceUntilIdle()
viewModel.onAction(ProfileEditAction.NameChanged("Bob"))
val state = viewModel.uiState.value
assertThat(state.nameError).isNull()
assertThat(state.isDirty).isTrue()
}
@Test
fun `SaveClicked on successful save emits NavigateBack effect`() = runTest {
advanceUntilIdle()
viewModel.onAction(ProfileEditAction.NameChanged("Bob"))
val effects = mutableListOf<ProfileEditEffect>()
val job = launch { viewModel.effect.collect { effects.add(it) } }
viewModel.onAction(ProfileEditAction.SaveClicked)
advanceUntilIdle()
assertThat(effects).contains(ProfileEditEffect.NavigateBack(savedSuccessfully = true))
job.cancel()
}
@Test
fun `SaveClicked on repository error emits ShowError effect`() = runTest {
fakeRepository.setShouldThrowOnUpdate(true)
advanceUntilIdle()
viewModel.onAction(ProfileEditAction.NameChanged("Bob"))
val effects = mutableListOf<ProfileEditEffect>()
val job = launch { viewModel.effect.collect { effects.add(it) } }
viewModel.onAction(ProfileEditAction.SaveClicked)
advanceUntilIdle()
assertThat(effects.filterIsInstance<ProfileEditEffect.ShowError>()).isNotEmpty()
assertThat(viewModel.uiState.value.isSaving).isFalse()
job.cancel()
}
@Test
fun `DiscardClicked with no changes triggers NavigateBack directly`() = runTest {
advanceUntilIdle()
val effects = mutableListOf<ProfileEditEffect>()
val job = launch { viewModel.effect.collect { effects.add(it) } }
viewModel.onAction(ProfileEditAction.DiscardClicked)
advanceUntilIdle()
assertThat(effects).contains(ProfileEditEffect.NavigateBack(savedSuccessfully = false))
job.cancel()
}
@Test
fun `DiscardClicked with changes emits ShowDiscardConfirmation`() = runTest {
advanceUntilIdle()
viewModel.onAction(ProfileEditAction.NameChanged("Changed"))
val effects = mutableListOf<ProfileEditEffect>()
val job = launch { viewModel.effect.collect { effects.add(it) } }
viewModel.onAction(ProfileEditAction.DiscardClicked)
advanceUntilIdle()
assertThat(effects).contains(ProfileEditEffect.ShowDiscardConfirmation)
job.cancel()
}
}
// Fake repository for testing
class FakeUserRepository : UserRepository {
private var user = User("", "", "")
private var shouldThrowOnUpdate = false
fun setUser(u: User) { user = u }
fun setShouldThrowOnUpdate(value: Boolean) { shouldThrowOnUpdate = value }
override suspend fun getCurrentUser(): User = user
override suspend fun updateProfile(name: String, email: String, bio: String) {
if (shouldThrowOnUpdate) throw IOException("Network error")
user = user.copy(name = name, email = email, bio = bio)
}
}
UDF Benefits Demonstrated
| Without UDF | With UDF |
|---|---|
| State scattered across UI and ViewModel | All state in one ViewModel UiState |
| UI directly mutates fields | UI sends typed actions, ViewModel decides |
| Side effects triggered from onClick | Side effects via SharedFlow — testable |
| Hard to test (need UI to trigger logic) | Reducer logic tested without UI |
| Possible state inconsistencies | State transitions explicit and atomic |
Key Takeaways
| Concept | Implementation |
|---|---|
UiState data class | Flat, no logic, derived values as computed properties |
UiAction sealed class | One subclass per user interaction |
| ViewModel reducer | when (action) block; _uiState.update { } for each case |
| Side effects | MutableSharedFlow with extraBufferCapacity = 1 |
| UI collection | collectAsStateWithLifecycle for state; LaunchedEffect for effects |
| Testing | Inject fake repository; test each action/state transition in isolation |