State Management in Jetpack Compose

Betül Necanlı
9 min readDec 16, 2023

State management is the process of tracking and updating the state of an application. In Jetpack Compose, state is represented by a value that can change over time.

📝A few examples of state in Android apps:

  • User input: The user’s input can be considered state, as it can change over time. For example, the user’s current location, the text they have entered into a text field, or the items they have selected in a list are all examples of state that can be changed by the user.
  • Data from the server: Data that is fetched from the server can also be considered state. For example, the weather forecast, the latest news headlines, or the current stock prices are all examples of data that can be fetched from the server and used to update the state of an app.
  • The app’s configuration: The app’s configuration can also be considered state. For example, the app’s current orientation, the user’s preferred language, or the device’s battery level can all affect the way an app behaves.
  • User Authentication State: Many apps require users to log in or sign up. The state of user authentication, such as whether a user is logged in or not, is an important piece of state in such apps. It determines what screens or features are accessible to the user and affects the behavior of various UI components.
  • Network Request State: When an app communicates with a server or an API, it typically involves network requests. The state of network requests includes whether a request is pending, completed, or encountered an error. This state is crucial for displaying loading spinners, handling errors, or updating UI components based on the result of the network request.

🚀State is an important concept in Android app development. By understanding how state works, you can create apps that are more responsive and user-friendly.

📝There are two main ways to manage state in Jetpack Compose:

  • Stateful composables: Stateful composables are composables that manage their own state. This state is typically stored within the composable function itself, using tools provided by Jetpack Compose, such as remember or mutableStateOf. When the state changes, the composable recomposes itself, updating the UI with the new state.
  • Stateless composables: Stateless composables do not have their own state. Instead, they rely on the state of their parents or siblings. When the state of a parent or sibling changes, the stateless composable is recomposed and the new state is used to render the UI.

Let’s create a composable function called CoffeeCounter that contains a Text composable that displays the number of cups of coffee. The number of cups should be stored in a value called coffeeCount, which you can hardcode for now:

The state of the CoffeeCounter composable function is the variable coffeeCount. But having a static state is not very useful, as it cannot be modified. To remedy this, we’ll add a Button to increase the count and track the number of cups of coffee we have throughout the day.

Now, let’s add the button so that users can modify the state by adding more cups of coffee.

The Button composable function receives an onClick lambda function — this is the event that happens when the button is clicked.

Change coffeeCount to var instead of val so it becomes mutable.

Nothing happens. 🤷🏻‍♀️

When we run the app and click the button, notice that nothing happens. This is because we haven’t told Compose that it should redraw the screen (that is, “recompose” the composable function), when the state changes.

👉The Composition: a description of the UI built by Jetpack Compose when it executes composables.

👉Initial composition: creation of a Composition by running composables the first time.

👉Recomposition: re-running composables to update the Composition when data changes.

Compose needs to know what state to track, so that when it receives an update it can schedule the recomposition.

Compose has a special state tracking system in place that schedules recompositions for any composables that read a particular state. This lets Compose be granular and just recompose those composable functions that need to change, not the whole UI. This is done by tracking not only “writes” (that is, state changes), but also “reads” to the state.

⭐️We need to use Compose’s State and MutableState types to make state observable by Compose.

Compose keeps track of each composable that reads State value properties and triggers a recomposition when its value changes. You can use mutableStateOf function to create an observable MutableState. It receives an initial value as a parameter that is wrapped in a State object, which then makes its value observable.

📖 Compose also has other variants of mutableStateOf, such as mutableIntStateOf, mutableLongStateOf, mutableFloatStateOf, or mutableDoubleStateOf, which are optimized for the primitive types.

Update CoffeeCounter composable so that coffeeCount uses the mutableStateOf API with 0 as the initial value. As mutableStateOf returns a MutableState type, we can update its value to update the state, and Compose will trigger a recomposition to those functions where its value is read.

As mentioned earlier, any changes to coffeeCount schedules a recomposition of any composable functions that read coffeeCount's value automatically. In this case, CoffeeCounter is recomposed whenever the button is clicked.

If we run the app now, we’ll notice again that nothing happens yet!

Scheduling recompositions is working fine. However, when a recomposition happens, the variable coffeeCount is re-initialized back to 0, so we need a way to preserve this value across recompositions.

For this we can use the remember composable inline function. A value calculated by remember is stored in the Composition during the initial composition, and the stored value is kept across recompositions.

⭐️ We can think of using remember as a mechanism to store a single object in the Composition, in the same way a private val property does in an object.

Usually remember and mutableStateOf are used together in composable functions.

Modify CoffeeCounter, surrounding the call to mutableStateOf with the remember inline composable function:

Alternatively, we could simplify the usage of coffeeCount by using Kotlin's delegated properties.

We can use the by keyword to define coffeeCount as a var. Adding the delegate's getter and setter imports lets us read and mutate coffeeCount indirectly without explicitly referring to the MutableState’s value property every time.

Now CoffeeCounter looks like this:

Our counter is ready and working! 🎉

This arrangement forms a data flow feedback loop with the user:

  • The UI presents the state to the user (the current count is displayed as text).
  • The user produces events that are combined with existing state to produce new state (clicking the button adds one to the current count)

Compose is a declarative UI framework. Instead of removing UI components or changing their visibility when state changes, we describe how the UI is under specific conditions of state. As a result of a recomposition being called and UI updated, composables might end up entering or leaving the Composition

This approach avoids the complexity of manually updating views as you would with the View system. It’s also less error-prone, as you can’t forget to update a view based on a new state, because it happens automatically.

If a composable function is called during the initial composition or in recompositions, we say it is present in the Composition. A composable function that is not called — for example, because the function is called inside an ifstatement and the condition is not met — -is absent from the Composition.

To demonstrate this, we’ll modify the Button so it's enabled until coffeeCount is 3 and is then disabled (and we reach our coffee limit for the day). Use the Button's enabled parameter to do this.

👉 remember stores objects in the Composition, and forgets the object if the source location where remember is called is not invoked again during a recomposition.

Enough coffee for the day ☕️

Let’s rotate the device and see what will happens.

Because Activity is recreated after a configuration change (in this case, orientation), the state that was saved is forgotten: the counter disappears as it goes back to 0.

📖 The same happens if you change language, switch between dark and light mode, or any other configuration change that makes Android recreate the running Activity.

While remember helps us retain state across recompositions, it's not retained across configuration changes. For this, we must use rememberSaveable instead of remember.

rememberSaveable automatically saves any value that can be saved in a Bundle. For other values, we can pass in a custom saver object.

In CoffeeCounter, replace remember with rememberSaveable:

Activity recreation is just one of the use cases of rememberSaveable.

⭐️ Use rememberSaveable to restore your UI state after an Activity is recreated. Besides retaining state across recompositions, rememberSaveable also retains state across Activity recreation and system-initiated process death.

A composable that uses remember to store an object contains internal state, which makes the composable stateful. This is useful in situations where a caller doesn't need to control the state and can use it without having to manage the state themselves. However, composables with internal state tend to be less reusable and harder to test.

Composables that don’t hold any state are called stateless composables. An easy way to create a statelesscomposable is by using state hoisting.

State hoisting in Compose is a pattern of moving state to a composable’s caller to make a composable stateless. The general pattern for state hoisting in Jetpack Compose is to replace the state variable with two parameters:

  • value: T — the current value to display
  • onValueChange: (T) -> Unit — an event that requests the value to change with a new value T

where this value represents any state that could be modified.

Stateful vs Stateless

👉A stateless composable is a composable that doesn’t own any state, meaning it doesn’t hold or define or modify new state.

👉A stateful composable is a composable that owns a piece of state that can change over time.

📖 In real apps, having a 100% stateless composable can be difficult to achieve depending on the composable’s responsibilities. We should design our composables in a way that they will own as little state as possible and allow the state to be hoisted, when it makes sense, by exposing it in the composable’s API.

We’ll refactor CoffeeCounter composable by splitting it into two parts: stateful and stateless Counter.

The role of the StatelessCounter is to display the coffeeCount and call a function when we increment the coffeeCount. To do this, we need to follow the pattern described above and pass the state, coffeeCount (as a parameter to the composable function), and a lambda (onIncrement), that is called when the state needs to be incremented. StatelessCounter looks like this:

StatefulCounter owns the state. That means that it holds the coffeeCount state and modifies it when calling the StatelessCounter function.

🎉 We hoisted coffeeCount from StatelessCounter to StatefulCounter.

⭐️ Key Point: When hoisting state, there are three rules to help you figure out where state should go:

State should be hoisted to at least the lowest common parent of all composables that use the state (read).

State should be hoisted to at least the highest level it may be changed (write).

If two states change in response to the same events they should be hoisted to the same level.

You can hoist the state higher than these rules require, but if you don’t hoist the state high enough, it might be difficult or impossible to follow unidirectional data flow.

As mentioned, state hoisting has some benefits.

  1. Our stateless composable can now be reused.

If juiceCount is modified then StatefulCounter is recomposed. During recomposition, Compose identifies which functions read juiceCount and triggers recomposition of only those functions.

When the user taps to increment juiceCount, StatefulCounter recomposes, and so does the StatelessCounter that reads juiceCount. But the StatelessCounter that reads coffeeCount and waterCount are not recomposed.

2. Our stateful composable function can provide the same state to multiple composable functions.

In this case, if the count is updated by either StatelessCounter or AnotherStatelessCounter, everything is recomposed, which is expected.

Because hoisted state can be shared, be sure to pass only the state that the composables need to avoid unnecessary recompositions, and to increase reusability.

Icon by Regular Life Icons
on freeicons.io
Icon by Regular Life Icons
on freeicons.io

--

--