Some Best-Practices for State Management in Jetpack Compose

Kayvan Kaseb
Software Development
9 min readApr 18, 2024
The picture is provided by Unsplash

As a matter of fact, Jetpack Compose is Android’s recommended modern toolkit for building native UI by Google. It offers a declarative UI approach that simplifies development compared to traditional methods. Additionally, Jetpack Compose offers various approaches to handle state effectively within your composable functions. This article will discuss some best-practices in Jetpack Compose for state management.

Introduction

As you know, Jetpack Compose is the modern toolkit for building native Android user interfaces (UIs). It provides a declarative UI approach that simplifies development compared to traditional methods using XML layouts. Unlike the traditional imperative approach using XML layouts, Compose uses a declarative style. You describe what your UI should look like and the desired state, and Compose takes care of rendering it on the screen and updating it dynamically.

Some Benefits of Jetpack Compose:

  • Declarative and Concise: Compose code is more concise and easier to read compared to verbose XML layouts.
  • Enhanced Maintainability: Separation of UI logic from data handling leads to cleaner and more maintainable code.
  • Dynamic Updates: Compose automatically updates the UI based on state changes. It simplifies dynamic UI handling.
  • Preview Tool: Compose offers a live preview tool for visualizing UI changes as you write code.

Composable Functions for Building Blocks of Compose UI:

Fundamentally, in Jetpack Compose, UI components are built using composable functions, which are annotated with @Composable. These functions describe what UI elements should be displayed based on their input parameters.

  • @Composable Annotation: This annotation marks a function as a composable.
  • Data Input: Composable functions can accept parameters, likeString to customize the UI based on data.
  • Emissive Nature: Composable functions do not return anything. They “emit” the UI hierarchy by calling other composable functions that represent UI elements like Text, Button, etc.
  • Focus on State: Composable functions describe the desired UI state based on the current data. Compose automatically manages rendering and updates.

For instance, a Composable function to show a text input field:

@Composable
fun TextInputField(value: String, onValueChange: (String) -> Unit) {
TextField(
value = value,
onValueChange = onValueChange,
label = { Text("Enter your text") }
)
}

Furthermore, state in an Android app is any value that can change over time. It covers a wide range of data in Android development, which can be modified and potentially affects the UI. Even though all data can change, state specifically refers to data that directly influences what the user observes or interacts with on the screen. For example, the username entered in a login form or the current score in a game are considered state because they affect the UI. So, effective state management is vital for building well-structured and maintainable Android apps. In fact, state management involves the techniques and tools used to track, update, and ensure consistent access to this data throughout your composable hierarchy.

Keep it clean, keep it Compose! State management best-practices lead to maintainable and performant UIs.

Leverage “remember" for efficient calculations

In composable, calculations can run frequently. Use remember to store the results of expensive calculations. This ensures the calculation runs just only once, and you can access the stored result whenever needed. This avoids redundant calculations on every Recomposition. Recomposition is the process of updating the UI based on changes to the underlying data or state. When the state of a composable function changes, Compose automatically recomposes the UI to reflect the new state. Recomposition is efficient because Compose only updates the parts of the UI that have changed. The most common use case is creating state variables using remember { mutableStateOf(initialValue) }. This allows the composable to track and update its internal state that can affect the UI.

Bad Code:

@Composable
fun ExpensiveCalculationScreen() {
val result = calculateSomethingExpensive()
Text(text = "Result: $result")
}

The code calculates something expensive on every Recomposition, even if the input has not changed.

Good Code:

@Composable
fun ExpensiveCalculationScreen() {
val result by remember(initialValue = { calculateSomethingExpensive() }) {
// ...
}
Text(text = "Result: $result")
}

remember stores the result of the calculation in the example. So, you can make sure about running just only once.

Important tips for remember:

remember stores objects in the Composition, and forgets the object when the composable that called remember is removed from the Composition.

  • Local to Composable Function: The value stored using remember is only accessible within the same composable function or its child composable. It is not shared across various composable functions.
  • Lifetime Management: The value stored with remember remains available as long as the composable function or its parent composable is alive in the composition hierarchy. When the composable is removed from the hierarchy, the stored value is also garbage collected.

Use state hoisting strategically

State hoisting in Compose is a pattern of moving state to a composable’s caller to make a composable stateless.

State hoisting involves lifting state up the composable tree to the closest common ancestor that needs that state. This keeps state updates efficient and avoids passing state through many composable functions.

Bad Code:

@Composable
fun ProfileScreen(userId: String) {
var isEditing by remember { mutableStateOf(false) }

// ... other composables

@Composable
fun UserName(userName: String) {
if (isEditing) {
TextField(value = userName, onValueChange = {}) // Needs access to isEditing
} else {
Text(text = userName)
}
}

@Composable
fun EditButton() {
Button(onClick = { isEditing = !isEditing }) { // Modifies state from ProfileScreen
Text(text = if (isEditing) "Done" else "Edit")
}
}

UserName(// Need to pass isEditing here
fetchData(userId)?.name ?: ""
)
EditButton()
}
  • State Passed Through Levels: isEditing is managed in ProfileScreen but needs to be passed down to both UserName and EditButton. This can clutter the code and potentially lead to unnecessary recompositions.
  • Modification from Deep Composable: EditButton directly modifies isEditing, which might trigger unwanted recompositions in ProfileScreen.

Good Code:

@Composable
fun ProfileScreen(userId: String) {
val (isEditing, setIsEditing) = remember { mutableStateOf(false) } // Hoisted state

// ...

@Composable
fun UserNameSection(userName: String) {
if (isEditing) {
TextField(value = userName, onValueChange = {})
} else {
Text(text = userName)
}
}

Column { // Common ancestor for UserNameSection and EditButton
UserNameSection(fetchData(userId)?.name ?: "")
EditButton(isEditing, setIsEditing)
}
}

@Composable
fun EditButton(isEditing: Boolean, setIsEditing: (Boolean) -> Unit) {
Button(onClick = { setIsEditing(!isEditing) }) {
Text(text = if (isEditing) "Done" else "Edit")
}
}

In the improved one, state hoisted to ProfileScreen: isEditing and its modification logic are lifted to ProfileScreen(the closest common ancestor needing that state). Additionally, both UserNameSection and EditButton receive isEditing and setIsEditing as parameters. This can boost clarity and decoupling composable from internal state management.

Important tips:

  • Do not hoist state unnecessarily high in the hierarchy (Over-Hoisting). Keep it at the lowest common ancestor that needs it.
  • Hoist the state to the highest composable function that can potentially modify the state. For example, if a counter can be incremented from multiple buttons, hoist the counter state to a parent that encompasses all those buttons. This gives you a chance for centralized updates.
  • If two or more pieces of state change in response to the same event or are inherently linked, hoist them together to the same level in the hierarchy. For instance, suppose a composable displaying username and email retrieved from an API call. Hoist both username and email state together to the same parent, as they are likely fetched and updated at the same time.

Deciding Between var and val

  • Use var when the state needs to be modified within the composable (e.g., user input, UI toggles). In the following example, var username by remember { mutableStateOf("") } creates a mutable state variable username initialized with an empty string.
@Composable
fun UsernameField() {
var username by remember { mutableStateOf("") }

TextField(
value = username,
onValueChange = { username = it },
label = "Username"
)
}
  • Use val with derivedStateOf for read-only state derived from other state or calculations, improving performance by avoiding unnecessary recompositions. For instance:
@Composable
fun UsernameField() {
var username by remember { mutableStateOf("") }

val characterCount = derivedStateOf { username.length }

TextField(
value = username,
onValueChange = { username = it },
label = "Username ($characterCount characters)"
)
}

val characterCount = derivedStateOf { username.length } creates a read-only state characterCount that reflects the current length of the username based on the username state. This derived state does not require to be mutable within this composable.

Choosing the Right State Holder

Bad Code:

@Composable
fun ShoppingCartScreen() {
var cartItems by remember { mutableStateOf(listOf<Product>()) }

Button(onClick = { cartItems.add(Product("New Item")) }) {
Text("Add Item")
}

//...
}

This code uses a mutable list directly for the cart, which will not trigger Recomposition when it changes

Good Code:

@Composable
fun ShoppingCartScreen() {

val cartItemsState = remember { mutableStateListOf<Product>() }

val addItemToCart: () -> Unit = {
cartItemsState.add(Product("New Item"))
}

Button(onClick = addItemToCart) {
Text("Add Item")
}

CartItemList(cartItems = cartItemsState)
}

@Composable
fun CartItemList(cartItems: List<Product>) {
// ..
}

Improvements in the above code:

State Management: cartItemsState uses remember { mutableStateListOf<Product>() } to store the cart items in a mutableStateListOf. This approach is appropriate for simple state management within a single composable, like ShoppingCartScreen.

Separation of Concerns: The addItemToCart function encapsulates the logic of adding an item to the cart. This promotes modularity and reusability. This function operates on the cartItemsState directly to simplify the state updates.

Event Handling: The Button utilizes onClick = addItemToCart to trigger the item addition functionality on button click. This event-driven approach promotes cleaner separation of UI elements and state updates.

However, if your app grows and requires more complex state management across screens or interactions with external data, consider exploring state management solutions, such as ViewModels or libraries like Redux in Compose.

Use ViewModels/State holder classes

In Jetpack Compose, state holder classes are a way to manage the state used by your composable functions. They offer a structured approach to encapsulate state logic and data, improving code organization, maintainability, and reusability. For state that needs to survive configuration changes or needs to be shared across the screen, use ViewModels or state holder classes. ViewModels are a good choice when the state is tied to the lifecycle of the composable screen.

Bad Code:

@Composable
fun UserSettingsScreen() {
var user by remember { mutableStateOf(User()) }

TextField(value = user.name, onValueChange = { user.name = it })

// Other user settings TextFields...
}

Jetpack Compose encourages immutability and unidirectional data flow. Directly mutating the user object breaks this rule and can make it harder to reason about the state changes in your UI. This leads to unexpected UI behavior.

Good Code:

@Composable
fun UserSettingsScreen(viewModel: UserSettingsViewModel) {

val userName by remember { derivedStateOf { viewModel.userName.value } }

TextField(
value = userName,
onValueChange = { viewModel.onUserNameChange(it) }
)

// Other user settings TextFields
}

The UserSettingsViewModel manages the user data and provides dedicated update functions for each setting. This keeps the composable clean and avoids direct mutation.

Consider LiveData integration

LiveData is an observable data holder from Android Architecture Components. It is designed for use with Activities and Fragments in a more imperative style. While you can use LiveData within composables, there are some key considerations: Direct usage of LiveData might trigger unnecessary Recomposition in composable even if the data has not truly changed. Also, composable functions typically prefer to work with state objects (like State<T>) for efficient Recomposition based on actual data changes. Thus, if your app uses LiveData, convert it to State before using it in composable. Use composable extension functions, like LiveData<T>.observeAsState() for this conversion. For instance:

val liveData = // Your LiveData instance

@Composable
fun MyComposable() {
val dataState = liveData.observeAsState(initial = null) // Convert LiveData to State

if (dataState.value != null) {
Text(text = "Data: ${dataState.value}") // Access data from State
} else {
// Handle loading state
}
}

Other important practices to consider

  1. Lazy and Efficient! Use Lazy layouts in Compose to reuse items and keep your UI smooth.
  2. Be mindful of Recomposition! Use derivedStateOf to avoid unnecessary UI updates in Compose.
  3. Read Later, Compose Now! Defer reads in Compose for optimal performance. Read data only when needed.
  4. Backwards writes? No thanks! ‍Avoid modifying state after reading in Compose. It leads to infinite loops!
  5. StateFlow for the win! Complex state and async operations? StateFlow’s got your back in Compose.

In Conclusion

Jetpack Compose offers various approaches to manage state effectively within your composable functions. For simple state needs within a single composable, you can use remember with mutable or read-only state variables. When state needs to be shared across multiple composable, state hoisting techniques become essential. This involves moving the state to the lowest common ancestor that needs to read it, and minimizing Recomposition. For complex state management, lifecycle awareness, or interaction with external data sources, ViewModels are the preferred choice. In addition, you can consider using immutable data types or observable wrappers for mutable data to ensure proper UI updates in Compose. By understanding these practices and choosing the right approach based on your state needs, you can build efficient, maintainable, and well-structured composable UIs.

--

--

Kayvan Kaseb
Software Development

Senior Android Developer, Technical Writer, Researcher, Artist, Founder of PURE SOFTWARE YAZILIM LİMİTED ŞİRKETİ https://www.linkedin.com/in/kayvan-kaseb