Back to Blog

Mastering State Management in Jetpack Compose

State management is the cornerstone of building reactive and efficient UI in Jetpack Compose. Understanding how to properly manage state not only ensures your app performs well but also makes your code more maintainable and testable. In this comprehensive guide, we'll explore various state management patterns and best practices.

Understanding State in Compose

In Compose, state represents any value that can change over time and affects the UI. When state changes, Compose automatically recomposes (redraws) only the parts of the UI that depend on that state.

Basic State with remember

The simplest way to manage state in Compose is using remember and mutableStateOf:

BasicState.kt
@Composable
fun CounterExample() {
    var count by remember { mutableStateOf(0) }
    
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.padding(16.dp)
    ) {
        Text(
            text = "Count: $count",
            style = MaterialTheme.typography.headlineMedium
        )
        
        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Button(onClick = { count-- }) {
                Text("-")
            }
            Button(onClick = { count++ }) {
                Text("+")
            }
        }
    }
}

State Hoisting

State hoisting is a pattern where you move state up to the common ancestor of composables that need to share it. This makes your composables stateless and more reusable:

StateHoisting.kt
@Composable
fun TodoApp() {
    var todos by remember { mutableStateOf(listOf()) }
    
    Column {
        TodoInput(
            onTodoAdd = { newTodo ->
                todos = todos + newTodo
            }
        )
        
        TodoList(
            todos = todos,
            onTodoToggle = { todo ->
                todos = todos.map { 
                    if (it.id == todo.id) it.copy(completed = !it.completed) 
                    else it 
                }
            }
        )
    }
}

@Composable
fun TodoInput(onTodoAdd: (Todo) -> Unit) {
    var text by remember { mutableStateOf("") }
    
    Row(
        modifier = Modifier.padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        OutlinedTextField(
            value = text,
            onValueChange = { text = it },
            label = { Text("New Todo") },
            modifier = Modifier.weight(1f)
        )
        
        Button(
            onClick = {
                if (text.isNotBlank()) {
                    onTodoAdd(Todo(text = text.trim()))
                    text = ""
                }
            },
            modifier = Modifier.padding(start = 8.dp)
        ) {
            Text("Add")
        }
    }
}

Advanced State Management

Using ViewModel

For complex state logic and to survive configuration changes, use ViewModel with Compose:

TodoViewModel.kt
class TodoViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(TodoUiState())
    val uiState: StateFlow = _uiState.asStateFlow()
    
    fun addTodo(text: String) {
        val newTodo = Todo(
            id = UUID.randomUUID().toString(),
            text = text,
            completed = false
        )
        
        _uiState.update { currentState ->
            currentState.copy(
                todos = currentState.todos + newTodo
            )
        }
    }
    
    fun toggleTodo(todoId: String) {
        _uiState.update { currentState ->
            currentState.copy(
                todos = currentState.todos.map { todo ->
                    if (todo.id == todoId) {
                        todo.copy(completed = !todo.completed)
                    } else todo
                }
            )
        }
    }
    
    fun deleteTodo(todoId: String) {
        _uiState.update { currentState ->
            currentState.copy(
                todos = currentState.todos.filter { it.id != todoId }
            )
        }
    }
}

data class TodoUiState(
    val todos: List = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

Using the ViewModel in your Composable:

TodoScreen.kt
@Composable
fun TodoScreen(
    viewModel: TodoViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    
    TodoContent(
        uiState = uiState,
        onAddTodo = viewModel::addTodo,
        onToggleTodo = viewModel::toggleTodo,
        onDeleteTodo = viewModel::deleteTodo
    )
}

@Composable
fun TodoContent(
    uiState: TodoUiState,
    onAddTodo: (String) -> Unit,
    onToggleTodo: (String) -> Unit,
    onDeleteTodo: (String) -> Unit
) {
    Column {
        TodoInput(onTodoAdd = onAddTodo)
        
        if (uiState.isLoading) {
            Box(
                modifier = Modifier.fillMaxWidth(),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        }
        
        LazyColumn {
            items(uiState.todos) { todo ->
                TodoItem(
                    todo = todo,
                    onToggle = { onToggleTodo(todo.id) },
                    onDelete = { onDeleteTodo(todo.id) }
                )
            }
        }
    }
}

Derived State

Use derivedStateOf when you need to compute state based on other state values. This optimization prevents unnecessary recompositions:

DerivedState.kt
@Composable
fun TodoStatistics(todos: List) {
    val completedCount by remember {
        derivedStateOf {
            todos.count { it.completed }
        }
    }
    
    val pendingCount by remember {
        derivedStateOf {
            todos.count { !it.completed }
        }
    }
    
    val completionPercentage by remember {
        derivedStateOf {
            if (todos.isEmpty()) 0f
            else completedCount.toFloat() / todos.size
        }
    }
    
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "Statistics",
                style = MaterialTheme.typography.headlineSmall
            )
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Text("Total: ${todos.size}")
            Text("Completed: $completedCount")
            Text("Pending: $pendingCount")
            
            LinearProgressIndicator(
                progress = completionPercentage,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 8.dp)
            )
        }
    }
}

State Management Best Practices

1. Single Source of Truth

Keep state in the lowest common ancestor and pass it down through parameters. This ensures data consistency and makes debugging easier.

2. Unidirectional Data Flow

Data should flow down through parameters, and events should flow up through callbacks. This pattern makes your app more predictable and testable.

UnidirectionalFlow.kt
@Composable
fun SearchScreen(
    viewModel: SearchViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    
    SearchContent(
        query = uiState.query,
        results = uiState.results,
        isLoading = uiState.isLoading,
        onQueryChange = viewModel::updateQuery,
        onSearch = viewModel::search,
        onResultClick = viewModel::selectResult
    )
}

3. Immutable Data

Use immutable data classes for your state. This prevents accidental mutations and makes state changes explicit:

ImmutableState.kt
@Immutable
data class UserProfile(
    val name: String,
    val email: String,
    val avatar: String,
    val preferences: UserPreferences
) {
    fun updateName(newName: String) = copy(name = newName)
    fun updateEmail(newEmail: String) = copy(email = newEmail)
}

@Immutable
data class UserPreferences(
    val theme: Theme,
    val notifications: Boolean,
    val language: String
)

4. State Scoping

Keep state as close as possible to where it's used. Don't lift state higher than necessary:

  • Use remember for UI-only state that doesn't need to survive configuration changes
  • Use rememberSaveable for UI state that should survive configuration changes
  • Use ViewModel for business logic state and data that outlives the composition
  • Use external state holders (Repository, UseCase) for app-wide state

Performance Considerations

Avoiding Unnecessary Recompositions

Compose is smart about recomposition, but you can help it by following these guidelines:

OptimizedComposable.kt
// ❌ This will cause unnecessary recompositions
@Composable
fun ExpensiveList(items: List) {
    LazyColumn {
        items(items) { item ->
            ExpensiveItem(
                item = item,
                onClick = { /* Handle click */ }, // New lambda every recomposition
                modifier = Modifier.padding(8.dp) // New modifier every recomposition
            )
        }
    }
}

// ✅ Optimized version
@Composable
fun OptimizedList(
    items: List,
    onItemClick: (Item) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier = modifier) {
        items(
            items = items,
            key = { it.id } // Provide stable keys
        ) { item ->
            ExpensiveItem(
                item = item,
                onClick = { onItemClick(item) }
            )
        }
    }
}

Testing State Management

Proper state management makes testing easier. Here's how to test your state logic:

ViewModelTest.kt
@Test
fun `when addTodo is called, todo is added to the list`() = runTest {
    val viewModel = TodoViewModel()
    
    viewModel.addTodo("Test todo")
    
    val uiState = viewModel.uiState.value
    assertEquals(1, uiState.todos.size)
    assertEquals("Test todo", uiState.todos.first().text)
    assertFalse(uiState.todos.first().completed)
}

@Test
fun `when toggleTodo is called, todo completion status changes`() = runTest {
    val viewModel = TodoViewModel()
    viewModel.addTodo("Test todo")
    
    val todoId = viewModel.uiState.value.todos.first().id
    viewModel.toggleTodo(todoId)
    
    val updatedTodo = viewModel.uiState.value.todos.first()
    assertTrue(updatedTodo.completed)
}

Conclusion

Mastering state management in Jetpack Compose is essential for building robust, performant, and maintainable Android applications. Start with simple remember for local UI state, use state hoisting for shared state, and leverage ViewModel for complex business logic.

Remember these key principles: maintain a single source of truth, ensure unidirectional data flow, use immutable data structures, and scope your state appropriately. With these patterns and best practices, you'll be well-equipped to handle even the most complex state management scenarios in your Compose applications.

Happy coding with Compose!