CPS251 Android Development by Scott Shaper

Hoisting State Up

Introduction

"Hoisting state up" means moving data and logic to a common parent so that multiple composables can share and update the same information. In Jetpack Compose, this is a key pattern for building apps that are easy to understand and maintain. It helps keep your UI predictable and your code organized.

When to Hoist State Up

  • When two or more composables need to read or change the same data
  • When you want to keep your UI components reusable and independent
  • When you want to separate your UI from your app's logic

Main Concepts

  • Single Source of Truth: Keep the data in one place (usually a parent composable or ViewModel) and pass it down to children.
  • Unidirectional Data Flow: Data flows down from parent to child, and events flow up from child to parent.
  • Stateless Composables: UI components that don't manage their own state are easier to reuse and test.

Why Hoist State? A Before and After Example

Let's look at a counter example first without hoisting, and then see how hoisting makes the code more maintainable and follows best practices:

Without Hoisting (More Complex to Maintain)


// Each composable manages its own state
@Composable
fun CounterScreen() {
    Column(
        modifier = Modifier
            .padding(top = 50.dp)
            .padding(16.dp)
    ) {
        // Display has its own state
        var displayCount by remember { mutableStateOf(0) }
        Text("Count: $displayCount")

        // Button has its own state
        var buttonCount by remember { mutableStateOf(0) }
        Button(onClick = { 
            buttonCount++
            displayCount = buttonCount  // We can sync them, but this gets messy
        }) {
            Text("Increment")
        }

        // If we add another button, we need to update both states
        Button(onClick = { 
            buttonCount--
            displayCount = buttonCount  // Duplicate code
        }) {
            Text("Increment Again")
        }

        // If we add another display, we need to update it too
        Text("Another Count: $displayCount")
    }
}
  
  • While we can keep the states in sync, it requires manual synchronization
  • Each new component that needs the count requires additional state updates
  • If we forget to update any state, the UI becomes inconsistent
  • As the app grows, this approach becomes harder to maintain and more prone to bugs
  • Components are tightly coupled because they need to know about each other's state

With Hoisting (Better Practice)


// Parent composable holds the state
@Composable
fun CounterParent() {
    var count by remember { mutableStateOf(0) }
    Column(
        modifier = Modifier
            .padding(top = 50.dp)
            .padding(16.dp)
    ) {
        CounterDisplay(count = count)
        CounterIncButton(onIncrement = { count++ })
        CounterDecButton(onDeIncrement = { count-- })
    }
}

/**
 * CounterDisplay is a stateless composable that shows the current count.
 * It receives the count as a parameter and simply displays it.
 * Being stateless makes it reusable and easier to test.
 */
@Composable
fun CounterDisplay(count: Int) {
    Text("Count: $count")
}

/**
 * CounterIncButton is a stateless composable that provides the increment functionality.
 * It receives a callback function (onIncrement) as a parameter.
 * When clicked, it triggers the callback to update the parent's state.
 */
@Composable
fun CounterIncButton(onIncrement: () -> Unit) {
    Button(onClick = onIncrement) {
        Text("Increment")
    }

}

/**
 * CounterDecButton is a stateless composable that provides the increment functionality.
 * It receives a callback function (onDeIncrement) as a parameter.
 * When clicked, it triggers the callback to update the parent's state.
 */
@Composable
fun CounterDecButton(onDeIncrement: () -> Unit){
    Button(onClick = onDeIncrement){
        Text("DeIncrement")
    }
}

  
  • State is managed in one place (single source of truth)
  • Adding new components is simple - just pass the state and callback
  • Components are decoupled - they don't need to know about each other
  • The code is more maintainable and less prone to bugs
  • Components are reusable because they're stateless

Note: While it's possible to keep states in sync without hoisting, hoisting is considered a best practice because it makes your code more maintainable, less prone to bugs, and easier to extend. It's especially important as your app grows in complexity.

Note: Because we did not use a view model, the state will not persist on rotation of the device. That being said for this example we could have used a view model and it would have done the same thing and persisted the state.

How this example renders

Above is just a snippet of the code to view the full code, you need to go to my GitHub page and look at the chapter10 hoisting.kt file.

How this example renders How this example renders

Tips for Success

  • Keep state as high as necessary, but as low as possible.
  • Make your UI components stateless whenever you can.
  • Pass data and event handlers (like onClick) down to children.

Common Mistakes to Avoid

  • Letting multiple composables manage the same piece of state (can cause bugs and confusion).
  • Making UI components manage their own state when they don't need to.
  • Not passing event handlers down, which makes it hard for children to update state.

Best Practices

  • Always keep a single source of truth for each piece of data.
  • Use stateless composables for UI whenever possible.
  • Document which composable owns the state and which are stateless.