How to Share State Between Composables in Jetpack Compose

State management is a cornerstone of modern UI development, and Jetpack Compose, Android’s declarative UI toolkit, provides powerful tools for managing and sharing state between composables. In this post, we will explore advanced techniques for state sharing in Jetpack Compose, discuss best practices, and examine how to avoid common pitfalls. Whether you’re building a single-screen app or a complex multi-screen application, understanding these concepts will help you create efficient and maintainable apps.

Why State Management Matters in Compose

In Jetpack Compose, composables are functions that declare the UI and its behavior. These composables can be stateless or stateful. For complex apps, managing and sharing state between composables ensures:

  • Consistency across the UI

  • Better separation of concerns

  • Easier testing and debugging

Jetpack Compose emphasizes unidirectional data flow, where state flows from a source (often a ViewModel or other state holder) to the composables that consume it. This architecture improves predictability and simplifies state updates.

Common Scenarios for Sharing State

  1. Parent-Child Communication: A parent composable holds state and passes it down to its children.

  2. Sibling Communication: Two or more sibling composables need to share state through a common ancestor.

  3. Cross-Screen Communication: Sharing state between composables across different screens in a navigation graph.

Strategies for Sharing State

1. Hoisting State

State hoisting is a Compose-specific term for moving state from a child composable to its parent. By hoisting state, you ensure that a composable remains stateless, enhancing reusability and testability.

Example:

@Composable
fun ParentComposable() {
    var text by remember { mutableStateOf("") }

    ChildComposable(
        text = text,
        onTextChange = { newText -> text = newText }
    )
}

@Composable
fun ChildComposable(text: String, onTextChange: (String) -> Unit) {
    TextField(
        value = text,
        onValueChange = onTextChange
    )
}

Key Points:

  • The parent manages the state.

  • The child receives state and updates via parameters.

2. Using ViewModel for Shared State

ViewModels are an essential part of state management in Compose, especially for cross-screen or lifecycle-aware state sharing. Using viewModel() allows composables to access shared state.

Example:

class SharedViewModel : ViewModel() {
    private val _counter = MutableStateFlow(0)
    val counter: StateFlow<Int> = _counter

    fun incrementCounter() {
        _counter.value++
    }
}

@Composable
fun ScreenA(viewModel: SharedViewModel = viewModel()) {
    val counter by viewModel.counter.collectAsState()

    Column {
        Text("Counter: $counter")
        Button(onClick = { viewModel.incrementCounter() }) {
            Text("Increment")
        }
    }
}

@Composable
fun ScreenB(viewModel: SharedViewModel = viewModel()) {
    val counter by viewModel.counter.collectAsState()

    Text("Shared Counter: $counter")
}

Key Points:

  • ViewModel provides a single source of truth.

  • State is shared between screens and survives configuration changes.

  • Use StateFlow or LiveData for reactivity.

3. Using CompositionLocal for Dependency Injection

CompositionLocal provides a mechanism to share state or dependencies with nested composables. It is akin to dependency injection for the composable tree.

Example:

val LocalUser = compositionLocalOf { "Guest" }

@Composable
fun AppContent() {
    CompositionLocalProvider(LocalUser provides "John Doe") {
        UserGreeting()
    }
}

@Composable
fun UserGreeting() {
    val user = LocalUser.current
    Text("Hello, $user!")
}

Key Points:

  • CompositionLocal is best for global or cross-cutting concerns.

  • Avoid using it for frequently changing state, as it can lead to inefficiencies.

4. Sharing State in Navigation Graphs

Jetpack Compose’s Navigation component supports ViewModel-based state sharing between screens. By scoping a ViewModel to the navigation graph, you can share state across multiple destinations.

Example:

@Composable
fun NavGraph(navController: NavHostController) {
    val sharedViewModel: SharedViewModel = viewModel()

    NavHost(navController = navController, startDestination = "screenA") {
        composable("screenA") {
            ScreenA(sharedViewModel, navController)
        }
        composable("screenB") {
            ScreenB(sharedViewModel)
        }
    }
}

Key Points:

  • Scope the ViewModel to the navigation graph.

  • Use NavBackStackEntry to retrieve the ViewModel when necessary.

5. State Sharing with Remember and MutableState

For transient, in-memory state sharing, you can use remember and mutableStateOf. This approach is suitable for local state that doesn’t need to persist across configuration changes.

Example:

@Composable
fun SharedCounter() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

Key Points:

  • State created with remember is bound to the composable’s lifecycle.

  • Not suitable for sharing state across multiple composables or screens.

Best Practices for Sharing State

  1. Use the Right Tool for the Job:

    • Use ViewModel for app-wide or screen-specific state.

    • Use remember for transient UI state.

    • Use CompositionLocal sparingly for dependencies.

  2. Avoid Over-Hoisting State:

    • Hoisting state unnecessarily can lead to bloated parent composables. Keep the state as close as possible to where it’s used.

  3. Optimize Recomposition:

    • Use derivedStateOf for expensive state transformations.

    • Split composables to minimize recomposition scopes.

  4. Ensure Unidirectional Data Flow:

    • Avoid bidirectional dependencies between composables to maintain clarity and predictability.

  5. Test State Management Logic:

    • Unit test ViewModel logic independently of composables.

    • Use tools like Compose Testing APIs to verify UI behavior.

Common Pitfalls to Avoid

  1. Overusing CompositionLocal:

    • Don’t use it for dynamic, frequently changing state. It’s better suited for static dependencies.

  2. Leaking State:

    • Ensure state objects are properly scoped and cleaned up to prevent memory leaks.

  3. Unnecessary Recomposition:

    • Avoid passing mutable state directly to multiple composables without memoization.

Conclusion

Sharing state between composables in Jetpack Compose is a critical skill for building robust and scalable apps. By mastering techniques like state hoisting, leveraging ViewModel, using CompositionLocal, and following best practices, you can design efficient and maintainable UIs. Understanding when and how to use these tools will help you create seamless and dynamic user experiences.

Jetpack Compose is constantly evolving, and state management will continue to play a central role in its ecosystem. Stay updated, experiment with different patterns, and refine your approach to build better Android apps.