androidengineers.Book a session

State Management

Exercise: UDF State Store in a Feature

exercise45 minMedium

Objective

Implement a Profile Edit screen using Unidirectional Data Flow (UDF). By the end you will have:

  • A typed UiState data class
  • A sealed UiAction class for all user interactions
  • A ViewModel that acts as a pure reducer of actions to state
  • A SharedFlow for 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:

  1. State flows down — the ViewModel holds state, the UI reads it
  2. Events flow up — the UI sends actions to the ViewModel, never mutates state directly
  3. 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 UDFWith UDF
State scattered across UI and ViewModelAll state in one ViewModel UiState
UI directly mutates fieldsUI sends typed actions, ViewModel decides
Side effects triggered from onClickSide effects via SharedFlow — testable
Hard to test (need UI to trigger logic)Reducer logic tested without UI
Possible state inconsistenciesState transitions explicit and atomic

Key Takeaways

ConceptImplementation
UiState data classFlat, no logic, derived values as computed properties
UiAction sealed classOne subclass per user interaction
ViewModel reducerwhen (action) block; _uiState.update { } for each case
Side effectsMutableSharedFlow with extraBufferCapacity = 1
UI collectioncollectAsStateWithLifecycle for state; LaunchedEffect for effects
TestingInject fake repository; test each action/state transition in isolation

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: UDF State Store in a Feature | Android System Design | Android Engineers