Understanding State Management in Jetpack Compose: Concepts, Best Practices, and Examples

Prashant Singh
6 min readJul 29, 2024

--

State & composition

Hey there! If you’re diving into Jetpack Compose, you’ve likely heard a lot about managing state. I’ve been working with Android and Jetpack Compose for a few years now, and I can tell you that getting a good grasp on state management is crucial for building responsive and dynamic UIs. In this article, we’ll explore key concepts like stateful and stateless composables, state hoisting, and how to use remember and rememberSavable. I'll share some examples and insights I've picked up along the way.

Declarative UI
Declarative UI

Declarative UI in Jetpack Compose

First things first, let’s talk about declarative UI. In a declarative framework like Jetpack Compose, you describe what the UI should look like for a given state, and Compose takes care of updating the UI when the state changes. This is different from imperative UI frameworks where you manually update the UI to reflect state changes.

Example of Declarative UI

@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}

In this simple example, the Greeting composable declares that it displays a Text with a greeting message. When the name parameter changes, the UI updates automatically. It's a neat and efficient way to handle UI.

Understanding @Composable

The @Composable annotation is the backbone of Jetpack Compose. It marks a function as a composable, meaning it can describe part of the UI and be part of Compose's UI tree. When you use @Composable, the Compose compiler does a lot of heavy lifting, managing the lifecycle, handling recompositions, and optimizing rendering.

How @Composable Works with the Compiler

When you annotate a function with @Composable, several things happen:

  1. UI Tree Construction: The function helps build the UI tree. Composable functions can call other composables, creating a hierarchy of UI elements.
  2. Lifecycle Management: The Compose compiler handles the lifecycle, ensuring composables are correctly recomposed when state changes.
  3. Optimization: The compiler optimizes recompositions, ensuring only the necessary parts of the UI update, which boosts performance.

Example of a Composable Function

@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}

Here, the Greeting function is marked with @Composable. The Compose compiler processes this function so it can be part of the composition and recomposition process.

Stateful and Stateless Composables

In Jetpack Compose, composables can be stateful or stateless. Knowing the difference is key to managing state effectively.

Stateful Composables

Stateful composables manage their own state internally. They retain state across recompositions but not across configuration changes like screen rotations.

Example of a Stateful Composable

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

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

In this example, the Counter composable manages its own count state. The state is preserved across recompositions, so the count increments correctly when the button is clicked.

Stateless Composables

Stateless composables don’t manage any state internally. They rely on external state passed as parameters. This makes them more reusable and easier to test.

Example of a Stateless Composable

@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}

In this example, the Greeting composable doesn’t manage any state. It simply displays the name passed to it.

remember and rememberSavable

Jetpack Compose gives you remember and rememberSavable to manage state within composables. These functions ensure state is retained across recompositions but handle state persistence differently.

remember

The remember function retains state across recompositions but doesn’t survive configuration changes or process death.

Example of using remember

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

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

In this example, the count state is preserved across recompositions of the Counter composable. However, if the screen is rotated, the count value resets.

rememberSavable

The rememberSavable function retains state across recompositions and survives configuration changes and process death by saving state in a Bundle.

Example of using rememberSavable

@Composable
fun CounterWithSave() {
var count by rememberSavable { mutableStateOf(0) }

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

In this example, the count state is preserved across recompositions and configuration changes. If the screen is rotated, the count value is restored.

State Hoisting

State hoisting is a pattern where you move state to a higher-level composable and pass it down to lower-level composables as parameters. This makes your code more modular, reusable, and easier to manage.

Implementing State Hoisting

Step 1: Identify the State

Determine which state needs to be managed and which composables use that state.

Step 2: Lift the State Up

Move the state to the nearest common ancestor of the composables that need to share it.

Step 3: Pass State Down

Pass the state and state update functions as parameters to the child composables.

Example of State Hoisting

1. Parent Composable Managing State:

@Composable
fun CounterApp() {
var count by remember { mutableStateOf(0) }
Counter(count = count, onIncrement = { count++ })
}

2. Child Composable Accepting State:

@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
Column {
Text(text = "Count: $count")
Button(onClick = onIncrement) {
Text(text = "Increment")
}
}
}

In this example, the CounterApp composable manages the count state and passes it down to the Counter composable. The Counter composable is stateless and relies on the state and state update function passed from its parent.

Benefits of State Hoisting

  1. Single Source of Truth: Having a single source of truth for the state at a higher level ensures consistency and makes the state easier to manage.
  2. Reusability: Stateless composables are more reusable because they rely on external state. They can be easily used in different contexts by passing different state values.
  3. Testability: Separating state management from UI logic makes it easier to write unit tests for both the state management logic and the UI components.
  4. Separation of Concerns: It promotes a clear separation between state management and UI rendering, leading to cleaner and more maintainable code.
  5. Composition: It makes it easier to compose complex UIs by combining smaller, stateless components that receive state from their parents.

More Complex Example

Consider a more complex example where multiple components need to share the same state.

Parent Composable Managing State:

@Composable
fun MyApp() {
var text by remember { mutableStateOf("Hello") }
Column {
TextField(value = text, onValueChange = { text = it })
TextDisplay(text = text)
}
}

Stateless Child Composables:

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

@Composable
fun TextDisplay(text: String) {
Text(text = text)
}

In this example, MyApp manages the text state and passes it to the TextField and TextDisplay composables. Both child composables are stateless and rely on the state passed from MyApp.

Summary

Managing state effectively in Jetpack Compose is crucial for building dynamic and responsive UIs. By understanding the differences between stateful and stateless composables, leveraging remember and rememberSavable, and implementing state hoisting, you can create efficient, maintainable, and reusable components.

  • Stateful Composables: Manage their own state internally and are useful for components with localized state that does not need to survive configuration changes.
  • Stateless Composables: Do not manage state internally and are more reusable and testable.
  • remember: Retains state across recompositions but not across configuration changes or process death.
  • rememberSavable: Retains state across recompositions and configuration changes by saving state in a Bundle.
  • State Hoisting: A design pattern that promotes separation of concerns and reusability by lifting state to a higher-level composable and passing it down as parameters.

By mastering these concepts and patterns, you can build robust and flexible UIs in Jetpack Compose. Happy coding!

--

--

No responses yet