Photo by Joseph James: Germany vs Canada — Mercedes-Benz Arena ✨

Mastering Jetpack Compose State Management: A Deep Dive into Modern UI Data Flow

Joseph James (JJ)
5 min readAug 13, 2023

--

In the ever-evolving landscape of Android development, Jetpack Compose has emerged as a game-changer, turning UI development from a mostly imperative to a fully declarative approach. One of the central elements that Compose revolutionizes is state management. The manner in which data flows, reacts, and is presented to the users has seen a fundamental shift. This article delves deep into the state management capabilities of Jetpack Compose, providing insights, examples, and best practices to harness the full power of this modern toolkit.

1. The Basics: State and MutableState

Every interactive app has underlying data that drives UI changes. Compose provides State (read-only) and MutableState (read-write).

val count by remember { mutableStateOf(0) }

In the above snippet, count is a piece of state. When it's modified, any composable that reads it will automatically recompose.

Here is an example of MutableState

val count = remember { mutableStateOf(0) }

// altering value
count.value = 10

As you can see in the above example, we can change the value of

2. Sharing State: Hoisting

Hoisting involves lifting the state to a higher-level composable. This promotes reusability and testability.

@Composable
fun EnhancedCounter(count: MutableState<Int>) {
Column {
Text("Count is: ${count.value}")
Button(onClick = { count.value++ }) {
Text("Increment")
}
}
}

With the count state being passed in, you can reuse EnhancedCounter in different contexts.

Im general, we will be lifting this to make interactions directly with VM to ensure the UDF (this elevation may go up to multiple composables).

3. Evolving State: Derived State

Sometimes, you want a state value that’s computed from other states. Compose has derivedStateOf to cater to this.

val isLoading = remember { mutableStateOf(true) }
val displayMessage = derivedStateOf { if (isLoading.value) "Loading..." else "Loaded!" }

Here, displayMessage will update when isLoading does.

4. Handling Side Effects: Effects in Compose

Side-effects are operations that interact with the outside world, like network requests or database operations.

Example using LaunchedEffect:

LaunchedEffect(key1 = someKey) {
// E.g., fetch data from a network
val data = fetchDataFromNetwork()
// Update state with data
}

maybe fetch api is not the correct example , but it serves the purpose 😄

5. Scaling with Architecture: Stateful Libraries

Larger apps require more structured state management. Enter ViewModel:

class MyViewModel : ViewModel() {
// This can be state flow
private val _data = MutableStateFlow<String>()
val data: StateFlow<String> get() = _data

init {
// update the "_data" upon initialization
}
}

@Composable
fun MyComposable() {
val viewModel: MyViewModel = viewModel()
val data by viewModel.data.observeAsState(initial = "Loading...")

CustomComposeFunction(data)
}

Here, ViewModel offers lifecycle-aware data storage, making it an excellent choice for more substantial apps.

6. The Power of Interaction: State and UI

The brilliance of Compose lies in how the UI responds to state changes.

Example with a text field:

@Composable
fun UserInput() {
val text = remember { mutableStateOf("") }

TextField(
value = text.value,
onValueChange = { newText -> text.value = newText },
label = { Text("Enter something") }
)
}

As the user types, the text state updates, and the TextField's value updates in tandem.

7. Persistence Across Configurations: Scoped State

For temporary data persistence (like across rotations):

val userInput = rememberSaveable { mutableStateOf("Initial Input") }

rememberSaveable ensures that temporary data survives configuration changes.

8. Sharing is Caring: Shared State

Jetpack Compose’s Composition Locals allow data to be provided at one point in the composable tree and then retrieved at a lower point in the tree without having to explicitly pass the data through each intermediary composable.

Why Composition Locals?

In traditional Android development, sharing data (like configurations, themes, or view models) between distant parts of the view hierarchy could be challenging. Compose’s Composition Locals simplify this by allowing data to be shared seamlessly across composables.

Defining a Composition Local

To create a new Composition Local, you use the compositionLocalOf function:

val LocalUserPreferences = compositionLocalOf { UserPreferences() }

In this example, we’re creating a new Composition Local for UserPreferences. The lambda provided is a default value provider which is used if no provider can be found up the composition tree.

Providing a Value

To share data, you’d use the CompositionLocalProvider composable:

@Composable
fun App() {
val userPreferences = UserPreferences(theme = "Dark")

CompositionLocalProvider(LocalUserPreferences provides userPreferences) {
MyAppContent()
}
}

Here, any composable inside MyAppContent (and its children) can access userPreferences via LocalUserPreferences.

Consuming a Value

To retrieve the value from a Composition Local:

@Composable
fun PreferencesDisplay() {
val preferences = LocalUserPreferences.current
Text("Theme: ${preferences.theme}")
}

In PreferencesDisplay, we're fetching the value of LocalUserPreferences using the current property. The retrieved preferences is an instance of UserPreferences set in the provider.

When to use Composition Locals?

While powerful, it’s essential to use Composition Locals judiciously:

  1. For Global Themes and Configurations: Composition Locals are great for values that many composables might need, like theme colors, typography, or global configurations.
  2. Avoid Overuse: It can make the data flow in your app less explicit if every piece of data is shared via Composition Locals. They shouldn’t replace regular state hoisting for more localized state.

9. Smooth Transitions: State and Animations

Jetpack Compose offers a variety of animation APIs to handle different use-cases. Central to these is the concept of an “animated state”. Essentially, this is a piece of state that transitions smoothly from one value to another, over time, whenever the state changes.

Basic Value Animation

To animate simple values (like numbers or colors), Compose offers the animate.AsState set of functions:

@Composable
fun AnimatedBox(isToggled: Boolean) {
val color by animateColorAsState(targetValue = if (isToggled) Color.Green else Color.Red)
Box(modifier = Modifier.background(color).size(100.dp))
}

In the example, the Box background color transitions smoothly between red and green based on the isToggled state.

Animating Position, Size, and More

Compose allows you to animate more complex properties, such as offsets or dimensions. For example, using animateDpAsState:

@Composable
fun AnimatedSizeBox(expanded: Boolean) {
val size by animateDpAsState(targetValue = if (expanded) 200.dp else 100.dp)
Box(modifier = Modifier.background(Color.Blue).size(size))
}

Here, the Box size animates between 100.dp and 200.dp based on the expanded state.

Transition Animations

For more advanced animations involving multiple properties or states, Compose offers the Transition API. It allows you to define different animation specs for entering, exiting, or between specific states:

enum class BoxState { Collapsed, Expanded }

@Composable
fun BoxWithTransition(currentState: BoxState) {
val transition = updateTransition(targetState = currentState, label = "BoxTransition")
val size by transition.animateDp(
transitionSpec = { if (targetState == BoxState.Collapsed) spring() else tween(500) },
label = "BoxSizeTransition"
) { state ->
if (state == BoxState.Collapsed) 100.dp else 200.dp
}
Box(modifier = Modifier.background(Color.Blue).size(size))
}

Animation Listeners

Sometimes, you might want to perform actions when an animation starts or ends. The Transition API provides listeners like onEachFrame and onEnd for such requirements.

AnimateContent

For animating content changes, you can use AnimatedContent:

@Composable
fun AnimatedContentExample(currentPage: Page) {
AnimatedContent(targetState = currentPage) { page ->
when (page) {
Page.HOME -> HomeContent()
Page.DETAILS -> DetailsContent()
}
}
}

The AnimatedContent composable transitions between different composables based on a state, like switching between different pages.

10. Testing Made Easy

State in Compose makes testing intuitive:

composeTestRule.setContent {
UserInput()
}

composeTestRule.onNodeWithLabel("Enter something").performTextInput("Compose Rocks!")
composeTestRule.onNodeWithText("Compose Rocks!").assertIsDisplayed()

In this test, we input text into a TextField and then verify its presence.

Conclusion

State management is the backbone of a Composable app. By understanding the intricacies and utilities provided by Jetpack Compose, you’ll be well-equipped to craft intuitive, interactive, and beautiful applications. Dive deep, and keep composing! 🚀

--

--

Joseph James (JJ)
Joseph James (JJ)

Responses (2)