State in Jetpack Compose is like the beating heart of dynamic UI elements. It enables your application to respond to user interactions or data changes, keeping your UI lively and responsive. However, managing this lifeblood can become challenging, particularly when dealing with complex UI hierarchies. It's like navigating a bustling city without a map. That's where state hoisting comes in. It acts as your city planner, improving the organization and maintainability of your Compose code by centralizing state management.
In this topic, you'll learn about the basics of state hoisting, its impact on the screen UI state, as well as the state of individual UI elements. Upon completion of this topic, you'll gain a solid understanding of state hoisting and how to use it effectively in your Jetpack Compose applications.
Basics
When we refer to state, we mean the data that changes over time and affects the behavior or output of a program — for example, the text within a text field or whether a checkbox is checked. Hoisting the state implies moving it up in the hierarchy so it's managed by a parent composable or the screen, rather than by individual UI elements.
Understanding state hoisting necessitates a comprehension of stateful and stateless functions. A stateful function is one that includes any state storage mechanism; for instance, a remember or rememberSaveable function. It can manage, store, and share the state with child or grandchild composables. In contrast, a stateless function does not manage or store the state; it simply displays the state provided by a parent composable or sends events back to the parent. The basic state flow can be visualised as follows.
We also need to understand what an event is. It is a user action, such as typing into a text field or clicking a button. Effectively managing these events can be challenging, especially in large projects with complex UIs composed of multiple components. These components may interact with each other, but unrelated elements should not share states and events. Indiscriminate mixing of states and events can lead to degraded performance and complicated code. State hoisting offers a solution by allowing flexible management of user events at the appropriate level within the composable hierarchy. However, it's crucial to hoist the state to the correct level. For instance, consider a card on the screen that maximizes when a button is clicked. Here, the button sends the event to the card, which then adjusts its height. In this scenario, the state is appropriately hoisted to the card, which acts as a parent, while the button serves as a child. Hoisting the state to the screen level might not be necessary, since the logic pertains specifically to the card rather than the entire screen.
This design pattern outlines responsibilities: the parent composable is stateful, as it maintains and controls the state, while the child composable is stateless, focusing solely on presenting the UI and dispatching user interactions without concerning itself with state management.
Screen UI state
Let's focus on how state hoisting impacts the screen UI state. In a Jetpack Compose application, screens are composed of multiple UI elements, each potentially having its own state. Through state hoisting, we delegate the responsibility of managing these states to the screen or even higher level.
Consider a simple form with multiple text fields. Each text field can have its own state — the current text. However, the form (or the screen containing the form) usually needs to know the overall state — the data entered across all fields. By employing state hoisting, we can manage all these states at the form level, thereby simplifying form validation or enabling quick actions based on the comprehensive data.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun FormScreen() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column {
AppTextField(
label = "Email",
value = email,
onValueChange = { email = it }
)
AppTextField(
label = "Password",
value = password,
onValueChange = { password = it }
)
Button(onClick = { /* Handle business logic */ }) {
Text("Submit")
}
}
}
@Composable
fun AppTextField(label: String, value: String, onValueChange: (String) -> Unit) {
TextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) }
)
}In this example, we have a FormScreen composable that contains two pieces of state: email and password. Each state is managed at the form level using remember and mutableStateOf. We then pass each piece of state down to an AppTextField composable, along with a corresponding onValueChange callback, which updates the state when the text changes.
The AppTextField composable is a simple wrapper around the TextField composable. It accepts a label, the current value, and an onValueChange callback as parameters. This setup makes each text field a pure (stateless) composable of its state, with no internal state management. Let's look at this setup in the form of a diagram.
Everything starts with the FormScreen. Here, we use mutableStateOf and remember to create a state for email and password, which will be preserved through recompositions. This state is then passed down to the TextField composables. The FormScreen is explicitly charged with managing the state, while the TextField composables are responsible for displaying input fields and updating the state as the user types, thanks to the onValueChange callback.
The orange arrows in the diagram represent events like onValueChange and onClick. These events are crucial because they trigger updates to our email and password. Without these events, the state would not change, and the TextField composables would not receive the updated information.
Another important aspect is that data flows in one direction—from FormScreen to TextField—while events that update the state, such as user inputs, flow in the opposite direction. This pattern is known as a unidirectional data flow. It makes it easier to track how data and events move through the app, thus simplifying debugging and problem-solving. It also helps prevent bugs that can occur when data moves in unexpected ways or is modified without your knowledge—a common issue with bidirectional data flow wherein data can move in both directions.
When dealing with more complex UI logic, it can be beneficial to encapsulate the state and related logic within a state–holder class. This class would manage the state and provide functions for its modification, contributing to a cleaner, more manageable UI code.
UI element state
Let's dive deeper into the state of individual UI elements. This pertains to the properties of a user interface component that determine its current behavior and presentation. Consider a Checkbox composable, for instance. It has a checked state that could either be true or false. In a non–hoisted scenario, the Checkbox would internally manage this state. However, with state hoisting, you pass the checked state down from the parent, and any changes are sent back via a callback.
Though this might seem like extra effort, it bears profound implications. Firstly, your Checkbox is now a pure function of its state—void of side effects or hidden behaviors, making it incredibly reusable. You can integrate it anywhere within your app without worrying about it misbehaving. Secondly, it allows for better separation of concerns. Your UI logic is now distinct from your UI presentation, which is a boon for testing and maintenance.
To implement this, you might write something like this:
@Composable
fun MyCheckbox(isChecked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Checkbox(
checked = isChecked,
onCheckedChange = onCheckedChange
)
}Notice how MyCheckbox takes both the current state and a method to update it as parameters. This is a minor modification from the default Checkbox, but it makes a difference in how you can manage the state of your app.
State holder class example
Let's create a state holder class for a hypothetical user profile form. This class will not only hold the values of the user's profile fields, but also include validation logic and a method to update the profile. This example demonstrates a state holder that goes beyond merely holding values—it manages the logical aspects tied to those values.
enum class ValidationState { Valid, Invalid, Unvalidated }
data class UserProfileUiState(
val email: String = "",
val password: String = "",
val isEmailValid: ValidationState = ValidationState.Unvalidated,
val isPasswordValid: ValidationState = ValidationState.Unvalidated
)We've created an enum class called ValidationState to represent the validation states of an email and password within a user interface. These states are Valid, Invalid, and Unvalidated:
Unvalidated: The email or password still needs to be validated.Valid: The email or password is valid according to the predefined rules.Invalid: The email or password is not valid.
Next, we defined a data class named UserProfileUiState to hold the state of the user profile UI. This class includes fields for the email and password and their respective validation states. By default, the email and password fields are initialized with empty strings, and their validation states are set to Unvalidated.
class UserProfileStateHolder() {
var userProfile by mutableStateOf(UserProfileUiState())
private set
fun updateEmail(email: String) {
userProfile = userProfile.copy(email = email)
}
fun updatePassword(password: String) {
userProfile = userProfile.copy(password = password)
}
// Validation logic
private fun checkEmailValidity(): Boolean { /*...*/ }
private fun checkPasswordValidity(): Boolean { /*...*/ }
fun submitProfileUpdates() {
val isEmailValid = checkEmailValidity()
val isPasswordValid = checkPasswordValidity()
if (isEmailValid && isPasswordValid) {
// handle valid state
} else {
// handle invalid state
}
}
}The UserProfileStateHolder class encapsulates both the state and behavior related to a user profile UI. It uses a mutableStateOf function to create a MutableState object, thereby enabling the UI to reactively update whenever the state changes. The userProfile property remains mutable within the class but cannot be directly modified from externally, owing to the private set modifier. This guarantees that the state can only be updated within the state holder.
The updateEmail and updatePassword methods facilitate updates to their respective fields in the state by creating a new, immutable copy of UserProfileUiState with the altered values. This pattern helps prevent unintended side effects and makes managing the state easier.
The checkEmailValidity and checkPasswordValidity methods contain the logic required to validate the email and password, typically checking for correct formatting and compliance with security standards. These methods are designated as private, given that they represent internal implementation aspects of the state holder.
When submitProfileUpdates is called, it uses these validation methods to determine the validity of profile updates. If they pass validation, it proceeds with handling the valid state, potentially by communicating with a repository or service to update the user profile in a database or backend system. Conversely, if the validation fails, the method handles the invalid state, possibly by editing the UserProfileUiState with relevant error messages for display to the user.
This class demonstrates a clean separation of concerns. By keeping the UI state and validation logic distinct from the UI components themselves, we enhance the maintainability and testability of the codebase.
@Composable
fun UserProfileScreen() {
val userProfileStateHolder = remember { UserProfileStateHolder() }
UserProfileForm(userProfileStateHolder)
}
@Composable
fun UserProfileForm(userProfileStateHolder: UserProfileStateHolder) {
val userProfile = userProfileStateHolder.userProfile
Column {
OutlinedTextField(
value = userProfile.email,
onValueChange = userProfileStateHolder::updateEmail,
label = { Text("Email") },
isError = userProfile.isEmailValid == ValidationState.Invalid
)
OutlinedTextField(
value = userProfile.password,
onValueChange = userProfileStateHolder::updatePassword,
label = { Text("Password") },
isError = userProfile.isPasswordValid == ValidationState.Invalid
)
Button(onClick = userProfileStateHolder::submitProfileUpdates) {
Text("Update Profile")
}
}
}In the above code snippet, we have two composables: UserProfileScreen and UserProfileForm. The UserProfileScreen composable is responsible for managing the user profile's state, while the UserProfileForm composable is tasked with rendering the UI elements allowing users to interact with the profile data.
UserProfileScreen creates an instance of UserProfileStateHolder using the remember function. This ensures that the UserProfileStateHolder instance is preserved across recompositions, avoiding the loss of state during UI updates. The UserProfileStateHolder contains the user profile state and provides access to methods for updating this state, such as updateEmail, updatePassword, and submitProfileUpdates.
UserProfileForm is a stateless composable, accepting an instance of the UserProfileStateHolder as a parameter. Leveraging the state and event handlers provided by UserProfileStateHolder, it effectively renders the UI:
OutlinedTextFieldcomposables are used for input fields. Thevalueparameter is bound to the current email or password from theuserProfilestate. TheonValueChangeparameter is a callback that invokes the corresponding update method inUserProfileStateHolderwhen the user modifies the text.The
labelparameter provides a textual label for each text field.The
isErrorparameter is a boolean that determines whether to display an error state for the text field. This is based on the validation state of the email or password appended to theuserProfilestate.The
Buttonis used to submit profile updates. When clicked, it calls thesubmitProfileUpdatesmethod fromUserProfileStateHolder.
In this architecture, UserProfileScreen is considered stateful, as it owns and manages the state of the user profile through UserProfileStateHolder. Conversely, UserProfileForm is deemed stateless because it does not possess any state of its own; rather, it renders UI elements based on the state and event handlers passed down from its parent composable.
By structuring the code this way, we achieve a clear separation of concerns: UserProfileScreen handles business logic and state management, while UserProfileForm focuses solely on presenting the UI.
Conclusion
In summary, state hoisting in Jetpack Compose is not merely a best practice—it signifies a paradigm shift that encourages you to conceptualize your app's state in a more centralized and controlled manner. Understanding and applying state hoisting ensures that your composables are pure, declarative, and easy to comprehend. The result is a codebase that's more manageable, testable, and scalable. Remember, state hoisting isn't just about elevating the state—it's also about enhancing the quality of your code. Embrace this pattern, and it will ensure a smoother Compose journey and robust apps for you.