Jetpack Compose Concepts
Where do you store state?
There are a few things you need to consider when deciding where to define your state:
- Where does the data come from?
- Does it need to persist across runs of the application?
- Does it need to persist if the device configuration changes (such as the user changing its orientation)?
- Is the data only used inside a composable function (perhaps it's a scrollbar position that's not needed outside)?
State defined outside of composable functions
Let's start by looking at a very simple Activity
that displays a person's name. An Activity
manages the user interface for your application. Whatever you call in setContent
will declare your Compose UI. setContent
creates the root Compose context; composable functions can only be called from it or from other composable functions.
class MainActivity : ComponentActivity() {
var person by mutableStateOf(Person("Scott"))
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Text(person.name)
}
}
}
Here we define person
inside the Activity
class, but outside of setContent
; it's not inside a composable function.
This works until the device configuration changes. A "configuration change" occurs when, among many other things, the user changes settings (such as language) on the device, folds/unfolds the screen of a foldable device, connects a device to Android Auto, or simply rotates the device from portrait to landscape.
The most common configuration change is device rotation (when "auto rotate" is set), so we'll use that as an example.
When an Activity
is created, it uses the device configuration to resolve any resources such as strings and images (and if you're using the old "views" based UIs, layouts, menus, themes and so on). To make this easy on the developer, by default, Android will destroy and recreate the Activity
instance so the developer doesn't need to worry about potentially stale resources.
The problem is, when the Activity
is destroyed and recreated, any properties it defines are also destroyed and recreated.
This includes person
in the above example. If person
had been changed, and the user rotates the device, poof! person
is reset to Person("Scott")
.
To deal with this, we could persist the person in a database, but if the data doesn't need to be retained across runs of the application, this is overkill.
ViewModels
This is where ViewModel
s come in. A ViewModel
, in Android terms, is a class that prepares/fetches/holds data for an Activity
as long as that Activity
is being used. This includes across configuration changes, so it's a great place to store state while the application is running.
We can create a view model like
class MyViewModel: ViewModel() {
var person by mutableStateOf(Person("Scott"))
}
Note
When adding by mutableStateOf(...)
to a file for the first time, you'll see some error underlines. If you float over the error, you see a message indicating that MutableState
has no method getValue(...)
and it can't be used as a property delegate. The basic MutableState
type itself isn't directly usable as a property delegate.
We can import
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
which are extension functions that adapt MutableState
for use as a property delegate.
You can import these by hitting alt-enter when your cursor is on the red squiggly underline, or clicking the lightbulb to the left of the line.
Back in our Activity
, we can use the
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MyViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PersonScreen(viewModel.person)
}
}
}
The property delegate created by viewModels<MyViewModel>()
will create a new instance of MyViewModel
when it doesn't yet exist for use with MainActivity
, and reuse an existing MyViewModel
if it had already been created for MainActivity
. Note that if you use viewModels<MyViewModel>
in an different Activity
class you'll get a different instance. That shouldn't be an issue, as you should usually only be creating a single Activity
for your application.
Now if we changed person
and rotated the screen, the same person
value will be available in the new instance of MainActivity
.
Note
While you can create a MutableState
in your view model, many don't feel comfortable with bringing a Compose detail into a view model. If you were thinking of sharing your view model across different types of UIs, such as a web UI, command line, or desktop in addition to Compose, you might want to consider using a Kotlin Flow
to store the data and collect it inside the UI. We'll use this Flow
approach in our examples once we start working with database data. For now, we'll use MutableState
.
For the record, I'm on the fence here. If you're only using a Compose UI, directly using MutableState
inside your view model is simpler all around. You'll likely end up with a mix of Flow
and MutableState
in your view model, though, as the data frameworks you likely use (like Room) can produce Flow
s but not MutableState
. The other side of my fence is that desire to keep the view model "clean" of UI specifics.
However - state is part of Compose runtime, which is separate from Compose UI. Unfortunately at the moment there are some inter-dependencies between the two. I hope that someday the runtime can be used separately from the ui, and then we could use runtime as an independent state manager with cool tree-building support.
State inside composable functions
Sometimes the right place to store data is inside a composable function. For example, you can define state that controls the scroll position of a list of items, and may not need it outside of that composable function.
@Composable
fun LongScreen(...) {
Column(
modifier = Modifier.verticalScroll(ScrollState(0))
) { ... }
}
In this definition, we're defining an instance of ScrollState
and passing it into a modifier for Column
that controls scrolling.
Unfortunately this won't work - every time we recompose LongScreen
, we create a new instance of ScrollState
at position 0; the page will never appear to scroll.
We need to create an instance of the ScrollState
and reuse it across recompositions. We do this using remember
:
@Composable
fun LongScreen(...) {
val scrollState = remember {
ScrollState(initial = 0)
}
Column(
modifier = Modifier.verticalScroll(scrollState)
) { ... }
}
remember
creates a spot in the subtree being declared by a composable function. Its lambda defines the value to store inside that spot. The lambda is only run on initial composition (but we can specify key data that forces it to reevaluate - more on that later).
In this case, we carve out a spot to store a ScrollState
inside the tree being declared. The value in that spot will be reused in recomposition, until this subtree no longer exists in the composition. (For example, if you switch to show a different screen in your application).
This works great until a configuration change occurs. Because the Activity
is destroyed and recreated, so is the UI. The existing tree is disposed and a new tree is created. This is one of the reasons it's so important that composable functions are idempotent - a tree representing the same data can be recreated.
If we want the data to survive a configuration change, we can use rememberSaveable
:
val scrollstate = rememberSaveable(saver = ScrollState.Saver) {
ScrollState(initial = 0)
}
Note that rememberSaveable
requires a saver
to be passed in - this describes how the data is saved across configuration changes. In this case, we have
val Saver: Saver<ScrollState, *> = Saver(
save = { it.value },
restore = { ScrollState(it) }
)
This saver
just returns the value of the ScrollState
(its position) to be stored and creates a new ScrollState
when being restored.
State objects like ScrollState
will often provide a shorthand for the remember
block. In this case, we can use
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState)
) { ... }
or, if you don't need to look at/modify the scroll state:
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) { ... }
As a more interesting example, we can look at TextField
for letting the user edit a value:
@Composable
fun MyTextField(
value: String, // AAA
onValueChange: (String) -> Unit, // BBB
) {
var displayedText by remember(value) { // CCC
mutableStateOf(value) // DDD
}
TextField(
value = displayedText, // EEE
onValueChange = {
displayedText = it // FFF
onValueChange(it) // GGG
}
)
}
The basic operation of MyTextField
is to pass in a value
(line AAA) to edit and an event function onValueChange
(line BBB) that informs the caller when the value has changed. In an ideal world, as the user types to change the field, the new value is sent to the caller who updates the value, triggering recomposition. The user would see the new value.
Unfortunately, we have no idea what the caller will be doing with the value and how long it will take to come back. Meanwhile the user keeps typing and more onValueChange
calls are made. This can result in race conditions and some "interesting" looking text if the user types quickly.
To respond as quickly as possible for the user while they're typing, we create an internal bucket to hold the displayed value:
var displayedText by remember(value) { // CCC
mutableStateOf(value) // DDD
}
On line CCC, we carve out a spot in the tree being declared by MyTextField
to hold our bucket. This spot is initialized via its lambda, creating the bucket on line DDD. The value
in remember(value)
is a key. Whenever that value changes, the lambda is re-executed, creating a new bucket with the new value from the outside.
On line EEE, we pass the value from the bucket to be displayed in the TextField
, and whenever the user makes a change, line FFF updates the bucket value, triggering recomposition of the TextField
.
Line GGG informs the caller of the change. When we implement this later, we'll debounce the update to limit how often we update the caller.
In this example we're using remember
and not rememberSaveable
. The source of truth for the value is coming from the outside, and will be passed in again after a configuration change, and the remember
will recreate the bucket based on that data.
Note
remember
is a composable function, but it returns a value! Any composable function that returns a value must not call composable functions that emit nodes to the tree! Any composable function that emits nodes to the tree must not return a value. Compose makes these assumptions and can perform some optimizations that won't work properly if you don't adhere to these conventions.