Jetpack Compose State and Recomposition

State is the cornerstone of how the Compose system is implemented. As such, a clear understanding of state is an essential step in becoming a proficient Compose developer. In this chapter, we will explore and demonstrate the basic concepts of state and explain the meaning of related terms such as recomposition, unidirectional data flow, and state hoisting. The chapter will also cover saving and restoring state through configuration changes.

The basics of state

In declarative languages such as Compose, state is generally referred to as “a value that can change over time”. At first glance, this sounds much like any other data in an app. A standard Kotlin variable, for example, is by definition designed to store a value that can change at any time during execution. State, however, differs from a standard variable in two significant ways.

First, the value assigned to a state variable in a composable function needs to be remembered. In other words, each time a composable function containing state (a stateful function) is called, it must remember any state values from the last time it was invoked. This is different from a standard variable which would be re-initialized each time a call is made to the function in which it is declared.

The second key difference is that a change to any state variable has far reaching implications for the entire hierarchy tree of composable functions that make up a user interface. To understand why this is the case, we now need to talk about recomposition.

Introducing recomposition

When developing with Compose, we build apps by creating hierarchies of composable functions. As previously discussed, a composable function can be thought of as taking data and using that data to generate sections of a user interface layout. These elements are then rendered on the screen by the Compose runtime system. In most cases, the data passed from one composable function to another will have been declared as a state variable in a parent function. This means that any change of state value in a parent composable will need to be reflected in any child composables to which the state has been passed. Compose addresses this by performing an operation referred to as recomposition.

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

Recomposition occurs whenever a state value changes within a hierarchy of composable functions. As soon as Compose detects a state change, it works through all of the composable functions in the activity and recomposes any functions affected by the state value change. Recomposing simply means that the function gets called again and passed the new state value.

Recomposing the entire composable tree for a user interface each time a state value changes would be a highly inefficient approach to rendering and updating a user interface. Compose avoids this overhead using a technique called intelligent recomposition that involves only recomposing those functions directly affected by the state change. In other words, only functions that read the state value will be recomposed when the value changes.

Creating the StateExample project

Launch Android Studio and select the New Project option from the welcome screen. Within the resulting new project dialog, choose the Empty Compose Activity template before clicking on the Next button.

Enter StateExample into the Name field and specify com.example.stateexample as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo). On completion of the project creation process, the StateExample project should be listed in the Project tool window located along the left-hand edge of the Android Studio main window.

Declaring state in a composable

The first step in declaring a state value is to wrap it in a MutableState object. MutableState is a Compose class which is referred to as an observable type. Any function that reads a state value is said to have subscribed to that observable state. As a result, any changes to the state value will trigger the recomposition of all subscribed functions.

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

Within Android Studio, open the MainActivity.kt file, delete the Greeting composable and modify the class so that it reads as follows:

package com.example.stateexample
.
.
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            StateExampleTheme {
                Surface(color = MaterialTheme.colors.background) {
                    DemoScreen()
                }
            }
        }
    }
}
 
@Composable
fun DemoScreen() {
    MyTextField()
}
 
@Composable
fun MyTextField() {
 
}
 
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    StateExampleTheme {
        DemoScreen()
    }
}Code language: Kotlin (kotlin)

The objective here is to implement MyTextField as a stateful composable function containing a state variable and an event handler that changes the state based on the user’s keyboard input. The result is a text field in which the characters appear as they are typed.

MutableState instances are created by making a call to the mutableStateOf() runtime function, passing through the initial state value. The following, for example, creates a MutableState instance initialized with an empty String value:

var textState = { mutableStateOf("") }Code language: Kotlin (kotlin)

This provides an observable state which will trigger a recomposition of all subscribed functions when the contained value is changed. The above declaration is, however, missing a key element. As previously discussed, state must be remembered through recompositions. As currently implemented, the state will be reinitialized to an empty string each time the function in which it is declared is recomposed. To retain the current state value, we need to use the remember keyword:

var myState = remember { mutableStateOf("") }Code language: Kotlin (kotlin)

Remaining within the MainActivity.kt file, add some imports and modify the MyTextField composable as follows:

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

.
.
import androidx.compose.material.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.foundation.layout.Column
.
.
@Composable
fun MyTextField() {
 
    var textState = remember { mutableStateOf("") }
 
    val onTextChange = { text : String ->
        textState.value = text
    }
 
    TextField(
        value = textState.value,
        onValueChange = onTextChange
    )
}Code language: Kotlin (kotlin)

Test the code using the Preview panel in interactive mode and confirm that keyboard input appears in the TextField as it is typed. Note that at the time of writing, keyboard input within the preview was not working. If you encounter a similar problem, run the app on an emulator or physical device to test.

When looking at Compose code examples, you may see MutableState objects declared in different ways. When using the above format, it is necessary to read and set the value property of the MutableState instance. For example, the event handler to update the state reads as follows:

val onTextChange = { text: String ->
     textState.value = text
}Code language: Kotlin (kotlin)

Similarly, the current state value is assigned to the TextField as follows:

TextField(
    value = textState.value,
    onValueChange = onTextChange
)Code language: Kotlin (kotlin)

A more common and concise approach to declaring state is to use Kotlin property delegates via the by keyword as follows (note that two additional libraries need to be imported when using property delegates):

.
.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
.
.
@Composable
fun MyTextField() {
 
    var textState by remember { mutableStateOf("") }
.
.Code language: Kotlin (kotlin)

We can now access the state value without needing to directly reference the MutableState value property within the event handler:

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

val onTextChange = { text: String ->
     textState = text
}Code language: Kotlin (kotlin)

This also makes reading the current value more concise:

TextField(
    value = textState,
    onValueChange = onTextChange
 )Code language: Kotlin (kotlin)

A third technique separates the access to a MutableState object into a value and a setter function as follows:

var (textValue, setText) = remember { mutableStateOf("") }Code language: Kotlin (kotlin)

When changing the value assigned to the state we now do so by calling the setText setter, passing through the new value:

val onTextChange = { text: String ->
     setText(text)
}Code language: Kotlin (kotlin)

The state value is now accessed by referencing textValue:

TextField(
    value = textValue,
    onValueChange = onTextChange
)Code language: Kotlin (kotlin)

In most cases, the use of the by keyword and property delegates is the most commonly used technique because it results in cleaner code. Before continuing with the chapter, revert the example to use the by keyword.

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

Unidirectional data flow

Unidirectional data flow is an approach to app development whereby state stored in a composable should not be directly changed by any child composable functions. Consider, for example, a composable function named FunctionA containing a state value in the form of a Boolean value. This composable calls another composable function named FunctionB that contains a Switch component. The objective is for the switch to update the state value each time the switch position is changed by the user. In this situation, adherence to unidirectional data flow prohibits FunctionB from directly changing the state value.

Instead, FunctionA would declare an event handler (typically in the form of a lambda) and pass it as a parameter to the child composable along with the state value. The Switch within FunctionB would then be configured to call the event handler each time the switch position changes, passing it the current setting value. The event handler in FunctionA will then update the state with the new value.

Make the following changes to the MainActivity.kt file to implement FunctionA and FunctionB together with a corresponding modification to the preview composable:

@Composable
fun FunctionA() {
 
    var switchState by remember { mutableStateOf(true) }
 
    val onSwitchChange = { value : Boolean ->
        switchState = value
    }
    
    FunctionB(
        switchState = switchState,
        onSwitchChange = onSwitchChange
    )
}
 
@Composable
fun FunctionB(switchState: Boolean, onSwitchChange : (Boolean) -> Unit ) {
    Switch(
        checked = switchState,
        onCheckedChange = onSwitchChange
    )
}
 
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    StateExampleTheme {
        Column {
            DemoScreen()
            FunctionA()
        }
    }
}Code language: Kotlin (kotlin)

Preview the app using interactive mode and verify that clicking the switch changes the slider position between on and off states.

We can now use this example to break down the state process into the following individual steps which occur when FunctionA is called:

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

  1. The switchState state variable is initialized with a true value.
  2. The onSwitchChange event handler is declared to accept a Boolean parameter which it assigns to switchState when called.
  3. FunctionB is called and passed both switchState and a reference to the onSwitchChange event handler.
  4. FunctionB calls the built-in Switch component and configures it to display the state assigned to switchState. The Switch component is also configured to call the onSwitchChange event handler when the user changes the switch setting.
  5. Compose renders the Switch component on the screen.

The above sequence explains how the Switch component gets rendered on the screen when the app first launches.

We can now explore the sequence of events that occur when the user slides the switch to the “off” position:

  1. The switch is moved to the “off” position.
  2. The Switch component calls the onSwitchChange event handler passing through the current switch position value (in this case false).
  3. The onSwitchChange lambda declared in FunctionA assigns the new value to switchState.
  4. Compose detects that the switchState state value has changed and initiates a recomposition.
  5. Compose identifies that FunctionB contains code that reads the value of switchState and therefore needs to be recomposed.
  6. Compose calls FunctionB with the latest state value and the reference to the event handler.
  7. FunctionB calls the Switch composable and configures it with the state and event handler.
  8. Compose renders the Switch on the screen, this time with the switch in the “off” position.

The key point to note about this process is that the value assigned to switchState is only changed from within FunctionA and never directly updated by FunctionB. The Switch setting is not moved from the “on” position to the “off” position directly by FunctionB. Instead, the state is changed by calling upwards to the event handler located in FunctionA, and allowing recomposition to regenerate the Switch with the new position setting.

As a general rule, data is passed down through a composable hierarchy tree while events are called upwards to handlers in ancestor components as illustrated in Figure 20-1:

Figure 20-1

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

State hoisting

If you look up the word “hoist” in a dictionary it will likely be defined as the act of raising or lifting something. The term state hoisting has a similar meaning in that it involves moving state from a child composable up to the calling (parent) composable or a higher ancestor. When the child composable is called by the parent, it is passed the state along with an event handler. When an event occurs in the child composable that requires an update to the state, a call is made to the event handler passing through the new value as outlined earlier in the chapter. This has the advantage of making the child composable stateless and, therefore, easier to reuse. It also allows the state to be passed down to other child composables later in the app development process. Consider our MyTextField example from earlier in the chapter:

@Composable
fun DemoScreen() {
    MyTextField()
}
 
@Composable
fun MyTextField() {
 
    var textState by remember { mutableStateOf("") }
 
    val onTextChange = { text : String ->
        textState = text
    }
 
    TextField(
        value = textState,
        onValueChange = onTextChange
    )
}Code language: Kotlin (kotlin)

The self-contained nature of the MyTextField composable means that it is not a particularly useful component. One issue is that the text entered by the user is not accessible to the calling function and, therefore, cannot be passed to any sibling functions. It is also not possible to pass a different state and event handler through to the function, thereby limiting its re-usability.

To make the function more useful we need to hoist the state into the parent DemoScreen function as follows:

@Composable
fun DemoScreen() {
 
    var textState by remember { mutableStateOf("") }
 
    val onTextChange = { text : String ->
        textState = text
    }
 
    MyTextField(text = textState, onTextChange = onTextChange)
}
 
@Composable
fun MyTextField(text: String, onTextChange : (String) -> Unit) {
 
    TextField(
        value = text,
        onValueChange = onTextChange
    )
}Code language: Kotlin (kotlin)

With the state hoisted to the parent function, MyTextField is now a stateless, reusable composable which can be called and passed any state and event handler. Also, the text entered by the user is now accessible by the parent function and may be passed down to other composables if necessary.

State hoisting is not limited to moving to the immediate parent of a composable. State can be raised any number of levels upward within the composable hierarchy and subsequently passed down through as many layers of children as needed (within reason). This will often be necessary when multiple children need access to the same state. In such a situation, the state will need to be hoisted up to an ancestor that is common to both children.

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

In Figure 20-2 below, for example, both NameField and NameText need access to textState. The only way to make the state available to both composables is to hoist it up to the MainScreen function since this is the only ancestor both composables have in common:

Figure 20-2

The solid arrows indicate the path of textState as it is passed down through the hierarchy to the NameField and NameText functions (in the case of the NameField, a reference to the event handler is also passed down), while the dotted line represents the calls from NameField function to an event handler declared in MainScreen as the text changes.

Note that if you find yourself passing state down through an excessive number of child layers, it may be worth looking at CompositionLocalProvider, a topic covered in the chapter entitled An Introduction to Composition Local.

When adding state to a function, take some time to decide whether hoisting state to the caller (or higher) might make for a more re-usable and flexible composable. While situations will arise where state is only needed to be used locally in a composable, in most cases it probably makes sense to hoist the state up to an ancestor.

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

Saving state through configuration changes

We now know that the remember keyword can be used to save state values through recompositions. This technique does not, however, retain state between configuration changes. A configuration change generally occurs when some aspect of the device changes in a way that alters the appearance of an activity (such as rotating the orientation of the device between portrait and landscape or changing a system-wide font setting).

Changes such as these will cause the entire activity to be destroyed and recreated. The reasoning behind this is that such changes affect resources such as the layout of the user interface and simply destroying and recreating impacted activities is the quickest way for an activity to respond to the configuration change. The result is a newly initialized activity with no memory of any previous state values.

To experience the effect of a configuration change, run the StateExample app on an emulator or device and, once running, enter some text so that it appears in the TextField before changing the orientation from portrait to landscape. When using the emulator, device rotation may be simulated using the rotation button located in the emulator toolbar. To complete the rotation on Android 11 or older, it may also be necessary to tap on the rotation button. This appears in the toolbar of the device or emulator screen as shown in Figure 20-3:

Figure 20-3

Before performing the rotation on Android 12 or later, you may need to enter the Settings app, select the Display category and enable the Auto-rotate screen option.

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

Note that after rotation, the TextField is now blank and the text entered has been lost. In situations where state needs to be retained through configuration changes, Compose provides the rememberSaveable keyword. When rememberSaveable is used, the state will be retained not only through recompositions, but also configuration changes. Modify the textState declaration to use rememberSaveable as follows:

.
.
import androidx.compose.runtime.saveable.rememberSaveable
.
.
@Composable
fun DemoScreen() {
 
    var textState by rememberSaveable { mutableStateOf("") }
.
.Code language: Kotlin (kotlin)

Build and run the app once again, enter some text and perform another rotation. Note that the text is now preserved following the configuration change.

Summary

When developing apps with Compose it is vital to have a clear understanding of how state and recomposition work together to make sure that the user interface is always up to date. In this chapter, we have explored state and described how state values are declared, updated, and passed between composable functions. You should also have a better understanding of recomposition and how it is triggered in response to state changes.

We also introduced the concept of unidirectional data flow and explained how data flows down through the compose hierarchy while data changes are made by making calls upward to event handlers declared within ancestor stateful functions.

An important goal when writing composable functions is to maximize reusability. This can be achieved, in part, by hoisting state out of a composable up to the calling parent or a higher function in the compose hierarchy.

 

You are reading a sample chapter from Jetpack Compose 1.5 Essentials.

Buy the full book now in Print or eBook format. Learn more.

Preview  Buy eBook  Buy Print

 

Finally, the chapter described configuration changes and explained how such changes result in the destruction and recreation of entire activities. Ordinarily, state is not retained through configuration changes unless specifically configured to do so using the rememberSaveable keyword.


Categories