This page looks best with JavaScript enabled

State Management Patterns in Jetpack Compose

 ·  ☕ 11 min read  ·  ✍️ Ignacio Carrión

State Management Patterns in Jetpack Compose

State management is a crucial aspect of building robust and maintainable Jetpack Compose applications. This article explores essential patterns and best practices for managing state effectively in your Compose UI, including immutable state, event-based updates, and testing strategies.

Understanding State Management Patterns

Effective state management in Compose requires understanding how to structure and handle state changes in a way that’s maintainable, testable, and scalable. This involves several key patterns:

  1. Immutable State Classes: Define clear state boundaries and prevent unintended modifications
  2. Event-Based Updates: Centralize state modifications through well-defined events
  3. Predictable State Flow: Ensure state changes follow a consistent pattern
  4. Testable Architecture: Structure code to facilitate thorough testing

Let’s explore each of these patterns in detail.

1. Single Source of Truth

The foundation of effective state management is maintaining a single source of truth for your application state. This pattern helps prevent inconsistencies and makes state changes more predictable.

The Single Source of Truth pattern involves using a sealed interface/class to represent all possible states of your UI. This approach provides several benefits:

  • Type-safe: The compiler ensures you handle all possible states
  • Consistent: All UI state comes from one authoritative source
  • Predictable: State transitions are explicit and traceable
  • Maintainable: Each state is a complete snapshot of the UI
  • Testable: State changes can be easily verified

Here’s how to implement this pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Example of Single Source of Truth pattern
sealed interface UserUiState {
    object Loading : UserUiState
    data class Error(val message: String) : UserUiState
    data class Success(val user: User) : UserUiState
}

class UserViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    fun loadUser(userId: String) {
        viewModelScope.launch {
            _uiState.emit(UserUiState.Loading)
            try {
                val user = userRepository.getUser(userId)
                _uiState.emit(UserUiState.Success(user))
            } catch (e: Exception) {
                _uiState.emit(UserUiState.Error(e.message ?: "Unknown error"))
            }
        }
    }
}

// The Composable can easily handle all states in one place
@Composable
fun UserScreen(viewModel: UserViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when (val state = uiState) {
        is UserUiState.Loading -> LoadingSpinner()
        is UserUiState.Error -> ErrorMessage(state.message)
        is UserUiState.Success -> {
            UserContent(state.user)
        }
    }
}

2. State Immutability

The second key pattern is maintaining immutable state objects. This approach is crucial for:

  • Preventing race conditions in concurrent operations
  • Making state changes explicit and traceable
  • Enabling efficient change detection in Compose
  • Simplifying debugging and testing

Here’s how to implement immutable state management:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// Define an immutable state class that represents all possible states
// Benefits:
// 1. Type-safe: Compiler ensures all properties are properly initialized
// 2. Thread-safe: Immutable objects are safe to share across threads
// 3. Predictable: State changes only happen through explicit copy operations
// 4. Debuggable: Each state change creates a new object, making it easy to track changes
data class SearchState(
    val query: String = "",
    val results: List<SearchResult> = emptyList(),
    val selectedResult: SearchResult? = null,
    val isLoading: Boolean = false
)

data class SearchResult(
    val id: String,
    val title: String,
    val description: String
)

class SearchViewModel : ViewModel() {
    // Encapsulate MutableStateFlow to ensure state updates only happen through defined methods
    private val _uiState = MutableStateFlow(SearchState())
    // Expose immutable StateFlow to prevent unauthorized modifications
    val uiState: StateFlow<SearchState> = _uiState.asStateFlow()

    // State updates are atomic and always create a new state object
    fun onQueryChange(query: String) {
        _uiState.update { it.copy(
            query = query,
            isLoading = query.isNotEmpty()
        ) }
        searchResults(query)
    }

    fun onResultSelected(result: SearchResult) {
        _uiState.update { it.copy(selectedResult = result) }
    }

    // Handle asynchronous operations and error cases while maintaining state consistency
    private fun searchResults(query: String) {
        viewModelScope.launch {
            try {
                val results = searchRepository.search(query)
                // Success: update results and reset loading state
                _uiState.update { it.copy(
                    results = results,
                    isLoading = false
                ) }
            } catch (e: Exception) {
                // Error: clear results and reset loading state
                _uiState.update { it.copy(
                    results = emptyList(),
                    isLoading = false
                ) }
            }
        }
    }
}

@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    // Collect state in a lifecycle-aware manner
    // The Composable automatically recomposes when state changes
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    // Declarative UI pattern:
    // 1. UI is a function of state
    // 2. State changes trigger recomposition
    // 3. UI automatically reflects the current state
    Column(modifier = Modifier.padding(16.dp)) {
        // Input: Current query and callback for changes
        SearchBar(
            query = state.query,
            onQueryChange = viewModel::onQueryChange
        )

        // Conditional rendering based on loading state
        if (state.isLoading) {
            CircularProgressIndicator()
        } else {
            // List rendering with stable keys for efficient updates
            LazyColumn {
                items(
                    items = state.results,
                    key = { it.id }
                ) { result: SearchResult ->
                    SearchResultItem(
                        result = result,
                        isSelected = result == state.selectedResult,
                        onClick = { viewModel.onResultSelected(result) }
                    )
                }
            }
        }
    }
}

3. Event-Based Updates

The third key pattern is using events to make state updates predictable. This approach helps to:

  • Centralize state modification logic
  • Make state transitions explicit and traceable
  • Ensure all state updates follow a consistent pattern
  • Simplify testing by verifying event handling

Here’s how to implement event-based state updates:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// Events represent all possible ways to modify the state
// Benefits of using events:
// 1. Type-safe: Compiler ensures all events are handled
// 2. Centralized: All state modifications go through a single point
// 3. Traceable: Easy to log and debug state changes
// 4. Testable: Events can be easily mocked and verified
sealed interface ProfileEvent {
    object LoadProfile : ProfileEvent
    data class UpdateBio(val newBio: String) : ProfileEvent
    data class UpdateName(val newName: String) : ProfileEvent
    object SaveProfile : ProfileEvent
}

// Immutable state for the profile screen
data class ProfileState(
    val name: String = "",
    val bio: String = "",
    val isLoading: Boolean = false,
    val isSaving: Boolean = false,
    val error: String? = null
)

class ProfileViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(ProfileState())
    val uiState: StateFlow<ProfileState> = _uiState.asStateFlow()

    // Single entry point for all state modifications
    // This ensures that:
    // 1. All state changes are handled consistently
    // 2. State modifications are easy to track
    // 3. Side effects are properly managed
    fun onEvent(event: ProfileEvent) {
        when (event) {
            is ProfileEvent.LoadProfile -> loadProfile()
            is ProfileEvent.UpdateBio -> updateBio(event.newBio)
            is ProfileEvent.UpdateName -> updateName(event.newName)
            is ProfileEvent.SaveProfile -> saveProfile()
        }
    }

    // Each event handler follows the same pattern:
    // 1. Update state to show operation in progress
    // 2. Perform the operation
    // 3. Update state with the result or error
    private fun loadProfile() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, error = null) }
            try {
                val profile = profileRepository.getProfile()
                _uiState.update { it.copy(
                    name = profile.name,
                    bio = profile.bio,
                    isLoading = false
                ) }
            } catch (e: Exception) {
                _uiState.update { it.copy(
                    isLoading = false,
                    error = e.message
                ) }
            }
        }
    }

    private fun updateBio(newBio: String) {
        _uiState.update { it.copy(bio = newBio) }
    }

    private fun updateName(newName: String) {
        _uiState.update { it.copy(name = newName) }
    }

    // For async operations, we handle loading states and errors consistently
    private fun saveProfile() {
        viewModelScope.launch {
            _uiState.update { it.copy(isSaving = true, error = null) }
            try {
                profileRepository.saveProfile(
                    name = uiState.value.name,
                    bio = uiState.value.bio
                )
                _uiState.update { it.copy(isSaving = false) }
            } catch (e: Exception) {
                _uiState.update { it.copy(
                    isSaving = false,
                    error = e.message
                ) }
            }
        }
    }
}

@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    // UI layer only needs to:
    // 1. Observe state changes
    // 2. Send events to the ViewModel
    // This creates a clear separation of concerns
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    // Load profile when screen is first displayed
    LaunchedEffect(Unit) {
        viewModel.onEvent(ProfileEvent.LoadProfile)
    }

    Column(modifier = Modifier.padding(16.dp)) {
        // Name field
        OutlinedTextField(
            value = state.name,
            onValueChange = { viewModel.onEvent(ProfileEvent.UpdateName(it)) },
            label = { Text("Name") }
        )

        // Bio field
        OutlinedTextField(
            value = state.bio,
            onValueChange = { viewModel.onEvent(ProfileEvent.UpdateBio(it)) },
            label = { Text("Bio") }
        )

        // Save button
        Button(
            onClick = { viewModel.onEvent(ProfileEvent.SaveProfile) },
            enabled = !state.isLoading && !state.isSaving
        ) {
            Text("Save Profile")
        }

        // Error message
        state.error?.let { error: String ->
            Text(
                text = error,
                color = MaterialTheme.colorScheme.error
            )
        }

        // Loading indicators
        if (state.isLoading) {
            CircularProgressIndicator(Modifier.align(Alignment.CenterHorizontally))
        }
    }
}

// This event-based pattern makes testing straightforward:
// 1. Create ViewModel with test dependencies
// 2. Send events
// 3. Verify state updates
// See the testing section below for detailed examples

4. Testing Strategy

The fourth key pattern is implementing a comprehensive testing strategy. Testing state management in Compose involves three main aspects:

  • Testing ViewModel state updates and transitions
  • Verifying event handling and side effects
  • Ensuring UI correctly reflects state changes

Here’s how to implement a complete testing strategy:

For ViewModel tests:

  • Initial state is correct
  • Events produce expected state updates
  • Async operations handle loading and error states
  • State updates are atomic and consistent

For UI tests:

  • Components reflect the current state
  • User interactions trigger correct events
  • Loading and error states are properly displayed

Here’s a complete example showing these testing patterns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// Test class demonstrates:
// 1. Proper test setup with dependencies
// 2. Testing different flows (success, error, immediate updates)
// 3. Verifying state transitions
class ProfileViewModelTest {
    private lateinit var viewModel: ProfileViewModel
    private lateinit var testRepository: TestProfileRepository

    @Before
    fun setup() {
        testRepository = TestProfileRepository()
        viewModel = ProfileViewModel(testRepository)
    }

    @Test
    fun `load profile - success flow`() = runTest {
        // Given
        val testProfile = Profile(name = "Test User", bio = "Test Bio")
        testRepository.setProfile(testProfile)

        // Initial state
        assertThat(viewModel.uiState.value)
            .isEqualTo(ProfileState())

        // When
        viewModel.onEvent(ProfileEvent.LoadProfile)

        // Then - Loading state
        assertThat(viewModel.uiState.value.isLoading).isTrue()
        assertThat(viewModel.uiState.value.error).isNull()

        // Complete async work
        advanceUntilIdle()

        // Then - Success state
        with(viewModel.uiState.value) {
            assertThat(isLoading).isFalse()
            assertThat(name).isEqualTo(testProfile.name)
            assertThat(bio).isEqualTo(testProfile.bio)
            assertThat(error).isNull()
        }
    }

    @Test
    fun `load profile - error flow`() = runTest {
        // Given
        testRepository.setShouldError(true)

        // When
        viewModel.onEvent(ProfileEvent.LoadProfile)
        advanceUntilIdle()

        // Then
        with(viewModel.uiState.value) {
            assertThat(isLoading).isFalse()
            assertThat(error).isNotNull()
        }
    }

    @Test
    fun `update name updates state immediately`() = runTest {
        // When
        viewModel.onEvent(ProfileEvent.UpdateName("New Name"))

        // Then
        assertThat(viewModel.uiState.value.name).isEqualTo("New Name")
    }
}

// UI tests verify the complete flow:
// 1. Initial state rendering
// 2. State updates reflection in UI
// 3. User interaction handling
@Test
fun `profile screen shows loading indicator and then content`() {
    // Create test rule
    composeTestRule.setContent {
        ProfileScreen(viewModel = viewModel)
    }

    // Verify initial loading state
    composeTestRule.onNode(hasTestTag("loading")).assertIsDisplayed()

    // Complete loading
    runTest {
        advanceUntilIdle()
    }

    // Verify content
    composeTestRule.onNode(hasText("Test User")).assertIsDisplayed()
    composeTestRule.onNode(hasText("Test Bio")).assertIsDisplayed()

    // Test interaction
    composeTestRule.onNode(hasText("Save Profile")).performClick()

    // Verify save event was handled
    verify(viewModel).onEvent(ProfileEvent.SaveProfile)
}

The combination of event-based state management and immutable states makes testing straightforward:

  • Events provide clear entry points for testing state changes
  • Immutable states make assertions simple and reliable
  • State transitions are easy to verify
  • UI tests can focus on state reflection and user interactions

Conclusion

Effective state management in Jetpack Compose requires a combination of patterns and best practices:

  1. Single Source of Truth

    • Use immutable state classes to represent all possible states
    • Keep state management centralized and predictable
    • Make state changes explicit and traceable
  2. State Immutability

    • Prevent unintended modifications with immutable state
    • Use data classes with copy for state updates
    • Maintain thread safety and predictability
  3. Event-Based Updates

    • Define clear events for all state modifications
    • Centralize state update logic
    • Make state transitions explicit and testable
  4. Testing Strategy

    • Test state transitions thoroughly
    • Verify UI updates reflect state changes
    • Ensure proper event handling

By following these patterns, you’ll build Compose applications that are:

  • More maintainable and easier to debug
  • Less prone to state-related bugs
  • Easier to test and verify
  • More scalable as complexity grows
Share on

Ignacio Carrión
WRITTEN BY
Ignacio Carrión
Android Developer