How to handle state in Jetpack Compose

10 min read
March 30, 2023

In the world of Android app development, managing state is a crucial but often challenging aspect of building a user interface.

Traditionally, developers have had to rely on complex and error-prone techniques such as callbacks, listeners, and observers to handle state changes in their UIs. 

However, with the introduction of Jetpack Compose, Google’s modern toolkit for building UIs, a new approach to state handling has emerged.

It uses a declarative approach, opposed to the old imperative approach, where the UI is described as a composition of smaller UI elements in a parent – child like pattern.

Here we’ll explore how to manage state in Jetpack Compose.

Let’s get to it!

State and Composition

Jetpack Compose’s state handling is declarative, meaning that you describe what the UI should look like based on its current state, rather than manually updating the UI in response to state changes. 

This allows developers to write less code, reduce boilerplate, and avoid common pitfalls associated with imperative state management techniques.

There are 2 ways to declare state in Jetpack Compose: view model state and compose state.

Let’s talk about them in more detail.

Compose State

Composable functions can use the remember API to store an object in memory. We call this method Compose State. 

A value computed by remember is stored in the Composition during initial composition, and the stored value is returned during recomposition. 

This makes it a good choice for managing local UI state that doesn’t need to persist across configuration changes.

Remember can be used to store both mutable and immutable objects.

development

Need an Android app?
We have a dedicated team just for you →

You’ll be talking with our technology experts.

In Jetpack Compose, state is represented by a mutableStateOf function, which takes an initial value and returns a MutableState object.

This object contains the current state of the UI element and can be passed around and modified as needed. When the state changes, Jetpack Compose automatically recomposes the UI, updating only the necessary parts of the UI to reflect the new state.

mutableStateOf creates an observable MutableState<T>, which is an observable type integrated with the compose runtime.

//code snippet

interface MutableState<T> : State<T> {

    override var value: T

}

There are three ways to declare a MutableState object in a composable to reflect the compose state:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

This makes it a good choice for managing local UI state that doesn’t need to persist across configuration changes.

View Model State

View model state is a state that is managed outside of a composable function, typically in a ViewModel class. It can be accessed by multiple composable functions and persists across configuration changes such as screen rotations.

This makes it a good choice for storing data that needs to survive configuration changes or for sharing data between composable functions.

Data layer UI state

Source: Android developers

Here is an example of how to create an UI state object in the view model class that holds a simple parameter “counter” with the default value to represent the initial screen configuration and some logic to increment that counter.

//code snippet

data class MyState(val counter: Int  = 0)

class MyViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(MyState())

    val uiState = _state.asStateFlow()

    fun incrementCounter() {

        _uiState .update { currentState ->

            currentState.copy(

                counter = currentState.counter++

            )

        }

    }

}

In this example, we define a MyState class to hold the counter state, and a MyViewModel class that uses MutableStateFlow to hold the state.

We define a state property that exposes the state as a StateFlow, which is an immutable stream of state updates that can be collected in a Composable using the collectAsState function.

We also define a incrementCounter function that updates the state atomically.

//code snippet

fun MyScreen(viewModel: MyViewModel) {

val uiState by viewModel.uiState.collectAsState()

    Column {

        Text("Counter value: ${uiState.counter}")

        Button(onClick = { viewModel.incrementCounter() }) {

            Text("Increment")

        }

    }

}

We then define a MyScreen composable function that collects the state using collectAsState and updates the UI when the state changes.

Recommendation

In general, it’s recommended to use view model state in Jetpack Compose for data that needs to survive configuration changes or for sharing data between composable functions.

 Compose state is more appropriate for managing local UI state that doesn’t need to persist across configuration changes.

However, there may be cases where it makes sense to use both view model state and compose state within the same composable function. 

For example, you could use view model state to store data that needs to persist across configuration changes and compose state to manage local UI state that doesn’t need to persist.

State Hoisting

State hoisting is a design pattern that helps manage the state of a Composable function by lifting the state up to a higher-level function or a parent Composable.

The idea behind state hoisting is to keep the stateful logic in a single place, usually in the parent Composable, and pass the state down as a parameter to the child Composables that need it. 

This helps keep the code more organized, easier to read and less prone to error.

state unidirectional flow

source: Android developers

Let’s talk about the best practices in state hoisting.

Best practice

Extract the state in the parent Composable either by using ViewModel State or Compose State approach.

 Then, pass it to the components that need to listen to the state changes. 

State should be kept closest to where it is consumed and not be passed to the child Composables, rather it should be exposed or modified from the state owner and referenced as immutable state by the state consumer.

You can learn more about this in the Unidirectional Data Flow section of this blog.

Stateful vs Stateless Components

Jetpack Compose has two types of components: 

  • stateful 
  • stateless.

Stateful components are those that have internal state that can change during the lifetime of the component. 

These components are created using the @Composable annotation and the “remember ” keyword. “remember ” is used to declare a variable that can be updated within the component, and its value will be preserved across recompositions. 

Examples of stateful components include forms, lists, and animations.

Stateless components, on the other hand, are those that don’t have any internal state and are purely a function of their input. These components are also created using the @Composable annotation, but they don’t use the remember keyword. 

Examples of stateless components include buttons, text labels, and icons.

Stateless vs stateful comparison.

Source: Jetpack Compose

Now let’s take a look at their differences.

Differences

The main difference between stateful and stateless components is that stateful components can update their internal state, while stateless components cannot. 

Stateful components are useful for building complex UIs that require dynamic behavior, such as handling user input and responding to changes in data. 

Stateless components, however, are useful for building simple UIs that don’t require any dynamic behavior.

Recommendation

In general, you should aim to use stateless components wherever possible. However, when you need to build a complex UI with dynamic behavior, you will likely need to use stateful components.

Another important aspect of managing state is Unidirectional Data Flow, which we’ll talk about next.

Unidirectional Data Flow 

Unidirectional data flow (UDF) is a design pattern where the UI is composed using a declarative programming model and the state of the UI is described by a hierarchy of composable functions.  

Unidirectional data flow.

source: Medium

In the Unidirectional Data Flow pattern, data flows in a single direction through the UI, from the top-level composable function down to the low-level composable functions. 

This means that data is passed down through the hierarchy of composable functions as function parameters, any changes to the UI are triggered by changes to this data.

The UI update loop for an app using unidirectional data flow looks like this:

  • Event – Part of the UI generates an event and passes it upward – button click event
  • Update state – An event handler might change the state – State Holder
  • Display state – The state holder passes down the state, and the UI displays it.

Following this pattern when using Jetpack Compose provides several advantages:

  • Testability – Decoupling state from the UI that displays it makes it easier to test
  • State encapsulation – Because state can only be updated in one place and there is only one source of truth for the state of a Composable, it’s less likely that you’ll create bugs due to inconsistent states.
  • UI consistency – All state updates are immediately reflected in the UI by the use of observable state holders, like StateFlow or LiveData.
@Composable

fun MyScreen(viewModel: MyViewModel) {

val uiState by viewModel.uiState.collectAsState()

  DisplayButton(

            text = uiState.counter.toString(),

            onClick = { viewModel.incrementCounter() }

        )

}

@Composable

fun DisplayButton(

    text: String,

    onClick: () -> Unit

) {

    Column {

        Text( text = "Counter value: $text")

        Button( onClick = onClick) {

            Text( "Increment" )

        }   

 }

}

       

As stated above here is a simple example how Unidirectional Data Flow works using the View Model State principle as state holder. 

First we create a parent Composable MyScreen that represents the entire screen of our application. In this parent Composable we extract the state using the collectAsState function.

Then we create another Composable, in this case, DisplayButton, that represents a component on the screen thus creating a hierarchy of Composable functions. The top-level Composable is MyScreen and the low-level Composable is DisplayButton. 

There can be multiple Composable functions which can have multiple Composable functions that build a proper hierarchy tree in the parent – child like pattern. 

The idea is to collect the state in the parent Composable and pass it down the hierarchy from top to bottom using the function parameters of the Composable function to achieve the Unidirectional Data Flow pattern and to conform to the single source of truth principle.

Having finished with UDF all it takes now is to conclude.

Handling state in Jetpack Compose: Conclusion

In conclusion, Jetpack Compose provides a powerful and flexible model for handling state in Android apps. 

By utilizing the above stated principles, developers can create UIs that are both easy to develop and maintain.

Using Jetpack Compose, the focus shifts from managing complex view hierarchies to a more declarative and reactive way of building user interfaces. 

This makes it easier to write code that is easier to read, test, and maintain. The ability to define state as part of the UI, Jetpack Compose opens up new possibilities for creating user interfaces that are dynamic and responsive to user interaction. 

Overall, Jetpack Compose provides a modern and efficient way to handle state in Android apps, and is well worth exploring for any developer looking to build better user interfaces.

If you’d like to read more blogs like this, check out our blog section.

Categories
Written by

Ivan Trogrlic

Software Engineering Team Lead

An Applied Sciences graduate and a true connoisseur of tech, Ivan is a software developer with a genuine love for exploring new technologies. QAs love his code, and his fellow developers always value his input. For Ivan, there is no issue too small to talk over, and no problem that can’t be solved together. When he is not coding, Ivan is usually hiking or playing football. His ideal workspace? Probably a cottage in the mountains, with a serious gaming setup and fast internet connection.

Related articles