Composables should be side-effect free. However, when they're necessary to mutate the state of the app, they should be called from a controlled environment that is aware of the lifecycle of the composable. In this page, you'll learn about the lifecycle of a composable and the different side-effect APIs Jetpack Compose offers.
Lifecycle of a composable
As mentioned in the Managing state documentation, a Composition describes the UI of your app and is produced by running composables. A Composition is a tree-structure of the composables that describe your UI.
When Jetpack Compose runs your composables for the first time, during initial composition, it will keep track of the composables that you call to describe your UI in a Composition. Then, when the state of your app changes, Jetpack Compose schedules a recomposition. Recomposition is when Jetpack Compose re-executes the composables that may have changed in response to state changes, and then updates the Composition to reflect any changes.
A Composition can only be produced by an initial composition and updated by recomposition. The only way to modify a Composition is through recomposition.
Figure 1. Lifecycle of a composable in the Composition. It enters the Composition, gets recomposed 0 or more times, and leaves the Composition.
Recomposition is typically triggered by a change to a
State<T>
object. Compose
tracks these and runs all composables in the Composition that read that
particular State<T>
, and any composables that they call that cannot be
skipped.
If a composable is called multiple times, multiple instances are placed in the Composition. Each call has its own lifecycle in the Composition.
@Composable
fun MyComposable() {
Column {
Text("Hello")
Text("World")
}
}
Figure 2. Representation of MyComposable
in the Composition. If a
composable is called multiple times, multiple instances are placed in the
Composition. An element having a different color is indicative of it being a
separate instance.
Anatomy of a composable in Composition
The instance of a composable in Composition is identified by its call site. The Compose compiler considers each call site as distinct. Calling composables from multiple call sites will create multiple instances of the composable in Composition.
If during a recomposition a composable calls different composables than it did during the previous composition, Compose will identify which composables were called or not called and for the composables that were called in both compositions, Compose will avoid recomposing them if their inputs haven't changed.
Preserving identity is crucial to associate side effects with their composable, so that they can complete successfully rather than restart for every recomposition.
Consider the following example:
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
LoginInput() // This call site affects where LoginInput is placed in Composition
}
@Composable
fun LoginInput() { /* ... */ }
In the code snippet above, LoginScreen
will conditionally call the
LoginError
composable and will always call the LoginInput
composable. Each
call has a unique call site and source position, which the compiler will use to
uniquely identify it.
Figure 3. Representation of LoginScreen
in the Composition when the state
changes and a recomposition occurs. Same color means it hasn't been recomposed.
Even though LoginInput
went from being called first to being called second,
the LoginInput
instance will be preserved across recompositions. Additionally,
because LoginInput
doesn’t have any parameters that have changed across
recomposition, the call to LoginInput
will be skipped by Compose.
Add extra information to help smart recompositions
Calling a composable multiple times will add it to Composition multiple times as well. When calling a composable multiple times from the same call site, Compose doesn’t have any information to uniquely identify each call to that composable, so the execution order is used in addition to the call site in order to keep the instances distinct. This behavior is sometimes all that is needed, but in some cases it can cause unwanted behavior.
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
// MovieOverview composables are placed in Composition given its
// index position in the for loop
MovieOverview(movie)
}
}
}
In the example above, Compose uses the execution order in addition to the call
site to keep the instance distinct in the Composition. If a new movie
is added
to the bottom of the list, Compose can reuse the instances already in the
Composition since their location in the list haven't changed and therefore, the
movie
input is the same for those instances.
Figure 4. Representation of MoviesScreen
in the Composition when a new
element is added to the bottom of the list. MovieOverview
composables in the
Composition can be reused. Same color in MovieOverview
means the composable
hasn't been recomposed.
However, if the movies
list changes by either adding to the top or the
middle of the list, removing or reordering items, it'll cause a recomposition
in all MovieOverview
calls whose input parameter has changed position in the
list. That's extremely important if, for example, MovieOverview
fetches a
movie image using a side effect. If recomposition happens while the effect is in
progress, it will be cancelled and will start again.
@Composable
fun MovieOverview(movie: Movie) {
Column {
// Side effect explained later in the docs. If MovieOverview
// recomposes, while fetching the image is in progress,
// it is cancelled and restarted.
val image = loadNetworkImage(movie.url)
MovieHeader(image)
/* ... */
}
}
Figure 5. Representation of MoviesScreen
in the Composition when a new
element is added to the list. MovieOverview
composables cannot be reused and
all side effects will restart. A different color in MovieOverview
means the
composable has been recomposed.
Ideally, we want to think of the identity of the MovieOverview
instance as
linked to the identity of the movie
that is passed to it. If we reorder the
list of movies, ideally we would similarly reorder the instances in the
Composition tree instead of recomposing each MovieOverview
composable with a
different movie instance. Compose provides a way for you to tell the runtime
what values you want to use to identify a given part of the tree: the
key
composable.
By wrapping a block of code with a call to the key composable with one or more
values passed in, those values will be combined to be used to identify that
instance in the composition. The value for a key
does not need to be
globally unique, it needs to only be unique amongst the invocations of
composables at the call site. So in this example, each movie
needs to have a
key
that's unique among the movies
; it's fine if it shares that key
with
some other composable elsewhere in the app.
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
key(movie.id) { // Unique ID for this movie
MovieOverview(movie)
}
}
}
}
With the above, even if the elements on the list change, Compose recognizes
individual calls to MovieOverview
and can reuse them.
Figure 6. Representation of MoviesScreen
in the Composition when a new
element is added to the list. Since the MovieOverview
composables have unique
keys, Compose recognizes which MovieOverview
instances haven't changed, and
can reuse them; their side effects will continue executing.
Some composables have built-in support for the key
composable. For example,
LazyColumn
accepts specifying a custom key
in the items
DSL.
@Composable
fun MoviesScreen(movies: List<Movie>) {
LazyColumn {
items(movies, key = { movie -> movie.id }) { movie ->
MovieOverview(movie)
}
}
}
Skipping if the inputs haven't changed
If a composable is already in the Composition, it can skip recomposition if all the inputs are stable and haven't changed.
A stable type must comply with the following contract:
- The result of
equals
for two instances will forever be the same for the same two instances. - If a public property of the type changes, Composition will be notified.
- All public property types are also stable.
There are some important common types that fall into this contract that the
compose compiler will treat as @Stable
, even though they are not explicitly
marked as @Stable
.
- All primitive value types:
Boolean
,Int
,Long
,Float
,Char
, etc. - Strings
- All Function types (lambdas)
All of these types are able to follow the contract of @Stable
because they are
immutable. Since immutable types never change, they never have to notify
Composition of the change, so it is much easier to follow this contract.
One notable type that is stable but is mutable is Compose’s MutableState
type. If a value is held in a MutableState
, the state object overall is
considered to be stable as Compose will be notified of any changes to the
.value
property of State
.
When all types passed as parameters to a composable are stable, the parameter values are compared for equality based on the composable position in the UI tree. Recomposition is skipped if all the values are unchanged since the previous call.
Compose considers a type stable only if it can prove it. For example, an interface is generally treated as not stable, and types with mutable public properties whose implementation could be immutable are not stable either.
If Compose is not able to infer that a type is stable, but you want to force
Compose to treat it as stable, mark it with the
@Stable
annotation.
// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
val value: T?
val exception: Throwable?
val hasError: Boolean
get() = exception != null
}
In the code snippet above, since UiState
is an interface, Compose could
ordinarily consider this type to be not stable. By adding the @Stable
annotation, you tell Compose that this type is stable, allowing Compose to favor
smart recompositions. This also means that Compose will treat all its
implementations as stable if the interface is used as the parameter type.
State and effect use cases
As covered in the Thinking in Compose documentation, composables should be side-effect free. When you need to make changes to the state of the app (as described in the Managing state documentation doc), you should use the Effect APIs so that those side effects are executed in a predictable manner.
Due to the different possibilities effects open up in Compose, they can be easily overused. Make sure that the work you do in them is UI related and doesn't break unidirectional data flow as explained in the Managing state documentation.
LaunchedEffect: run suspend functions in the scope of a composable
To call suspend functions safely from inside a composable, use the
LaunchedEffect
composable. When LaunchedEffect
enters the Composition, it launches a
coroutine with the block of code passed as a parameter. The coroutine will be
cancelled if LaunchedEffect
leaves the composition. If LaunchedEffect
is
recomposed with different keys (see the Restarting
Effects section below), the existing coroutine will be
cancelled and the new suspend function will be launched in a new coroutine.
For example, showing a Snackbar
in a Scaffold
is done with the SnackbarHostState.showSnackbar
function, which is a suspend function.
@Composable
fun MyScreen(
state: UiState<List<Movie>>,
scaffoldState: ScaffoldState = rememberScaffoldState()
) {
// If the UI state contains an error, show snackbar
if (state.hasError) {
// `LaunchedEffect` will cancel and re-launch if `scaffoldState` changes
LaunchedEffect(scaffoldState.snackbarHostState) {
// Show snackbar using a coroutine, when the coroutine is cancelled the
// snackbar will automatically dismiss. This coroutine will cancel whenever
// `state.hasError` is false, and only start when `state.hasError`
// is true (due to the above if-check), or if `scaffoldState` changes.
scaffoldState.snackbarHostState.showSnackbar(
message = "Error message",
actionLabel = "Retry message"
)
}
}
Scaffold(scaffoldState = scaffoldState) {
/* ... */
}
}
In the code above, a coroutine is triggered if the state contains an error and
it'll be cancelled when it doesn't. As the LaunchedEffect
call site is inside
an if statement, when the statement is false, if LaunchedEffect
was in the
Composition, it'll be removed, and therefore, the coroutine will be cancelled.
rememberCoroutineScope: obtain a composition-aware scope to launch a coroutine outside a composable
As LaunchedEffect
is a composable function, it can only be used inside other
composable functions. In order to launch a coroutine outside of a composable,
but scoped so that it will be automatically canceled once it leaves the
composition, use
rememberCoroutineScope
.
Also use rememberCoroutineScope
whenever you need to control the lifecycle of
one or more coroutines manually, for example, cancelling an animation when a
user event happens.
rememberCoroutineScope
is a composable function that returns a
CoroutineScope
bound to the point of the Composition where it's called. The
scope will be cancelled when the call leaves the Composition.
Following the previous example, you could use this code to show a Snackbar
when the user taps on a Button
:
@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {
// Creates a CoroutineScope bound to the MoviesScreen's lifecycle
val scope = rememberCoroutineScope()
Scaffold(scaffoldState = scaffoldState) {
Column {
/* ... */
Button(
onClick = {
// Create a new coroutine in the event handler
// to show a snackbar
scope.launch {
scaffoldState.snackbarHostState
.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
rememberUpdatedState: reference a value in an effect that shouldn't restart if the value changes
LaunchedEffect
restarts when one of the key parameters changes. However, in
some situations you might want to capture a value in your effect that, if it
changes, you do not want the effect to restart. In order to do this, it is
required to use rememberUpdatedState
to create a reference to this value which
can be captured and updated. This approach is helpful for effects that contain
long-lived operations that may be expensive or prohibitive to recreate and
restart.
For example, suppose your app has a LandingScreen
that disappears after some
time. Even if LandingScreen
is recomposed, the effect that waits for some time
and notifies that the time passed shouldn't be restarted:
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes, the delay shouldn't start again.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
/* Landing screen content */
}
To create an effect that matches the lifecycle of the call site, a
never-changing constant like Unit
or true
is passed as a parameter. In the
code above, LaunchedEffect(true)
is used. To make sure that the onTimeout
lambda always contains the latest value that LandingScreen
was recomposed
with, onTimeout
needs to be wrapped with the rememberUpdatedState
function.
The returned State
, currentOnTimeout
in the code, should be used in the
effect.
DisposableEffect: effects that require cleanup
For side effects that need to be cleaned up after the keys change or if the
composable leaves the Composition, use
DisposableEffect
.
If the DisposableEffect
keys change, the composable needs to dispose (do
the cleanup for) its current effect, and reset by calling the effect again.
As example, an
OnBackPressedCallback
needs to be registered to listen for the back button being pressed on a
OnBackPressedDispatcher
.
To listen for those events in Compose, use a DisposableEffect
to register and
unregister the callback when needed.
@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
// Safely update the current `onBack` lambda when a new one is provided
val currentOnBack by rememberUpdatedState(onBack)
// Remember in Composition a back callback that calls the `onBack` lambda
val backCallback = remember {
// Always intercept back events. See the SideEffect for
// a more complete version
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
currentOnBack()
}
}
}
// If `backDispatcher` changes, dispose and reset the effect
DisposableEffect(backDispatcher) {
// Add callback to the backDispatcher
backDispatcher.addCallback(backCallback)
// When the effect leaves the Composition, remove the callback
onDispose {
backCallback.remove()
}
}
}
In the code above, the effect will add the remembered backCallback
to the
backDispatcher
. If backDispatcher
changes, the effect is disposed and
restarted again.
A DisposableEffect
must include an onDispose
clause as the final statement
in its block of code. Otherwise, the IDE displays a build-time error.
SideEffect: publish Compose state to non-compose code
To share Compose state with objects not managed by compose, use the
SideEffect
composable, as it's invoked on every successful recomposition.
Taking the previous BackHandler
code as an example, to communicate whether the
callback should be enabled or not, use SideEffect
to update its value.
@Composable
fun BackHandler(
backDispatcher: OnBackPressedDispatcher,
enabled: Boolean = true, // Whether back events should be intercepted or not
onBack: () -> Unit
) {
/* ... */
val backCallback = remember { /* ... */ }
// On every successful composition, update the callback with the `enabled` value
// to tell `backCallback` whether back events should be intercepted or not
SideEffect {
backCallback.isEnabled = enabled
}
/* Rest of the code */
}
produceState: convert non-Compose state into Compose state
produceState
launches a coroutine scoped to the Composition that can push values into a
returned State
. Use it to
convert non-Compose state into Compose state, for example bringing external
subscription-driven state such as Flow
, LiveData
, or RxJava
into the
Composition.
The producer is launched when produceState
enters the Composition, and will be
cancelled when it leaves the Composition. The returned State
conflates;
setting the same value won't trigger a recomposition.
Even though produceState
creates a coroutine, it can also be used to observe
non-suspending sources of data. To remove the subscription to that source, use
the
awaitDispose
function.
The following example shows how to use produceState
to load an image from the
network. The loadNetworkImage
composable function returns a State
that can
be used in other composables.
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository
): State<Result<Image>> {
// Creates a State<T> with Result.Loading as initial value
// If either `url` or `imageRepository` changes, the running producer
// will cancel and will be re-launched with the new keys.
return produceState(initialValue = Result.Loading, url, imageRepository) {
// In a coroutine, can make suspend calls
val image = imageRepository.load(url)
// Update State with either an Error or Success result.
// This will trigger a recomposition where this State is read
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
derivedStateOf: convert one or multiple state objects into another state
Use
derivedStateOf
when a certain state is calculated or derived from other state objects. Using
this function guarantees that the calculation will only occur whenever one of
the states used in the calculation changes.
The following example shows a basic To Do list whose tasks with user-defined high priority keywords appear first:
@Composable
fun TodoList(
highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")
) {
val todoTasks = remember { mutableStateListOf<String>() }
// Calculate high priority tasks only when the todoTasks or
// highPriorityKeywords change, not on every recomposition
val highPriorityTasks by remember(todoTasks, highPriorityKeywords) {
derivedStateOf {
todoTasks.filter { it.containsWord(highPriorityKeywords) }
}
}
Box(Modifier.fillMaxSize()) {
LazyColumn {
items(highPriorityTasks) { /* ... */ }
items(todoTasks) { /* ... */ }
}
/* Rest of the UI where users can add elements to the list */
}
}
In the code above, derivedStateOf
guarantees that whenever todoTasks
or
highPriorityKeywords
changes, the highPriorityTasks
calculation will occur
and the UI will be updated accordingly. As the filtering to calculate
highPriorityTasks
can be expensive, it should only be executed when any of the
lists change, not on every recomposition.
Furthermore, an update to the state produced by derivedStateOf
doesn't cause
the composable where it's declared to recompose, Compose only recomposes those
composables where its returned state is read, inside LazyColumn
in the
example.
Restarting effects
Some effects in Compose, like LaunchedEffect
, produceState
, or
DisposableEffect
, take a variable number of arguments, keys, that are used to
cancel the running effect and start a new one with the new keys.
The typical form for these APIs is:
EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
Due to the subtleties of this behavior, problems can occur if the parameters used to restart the effect are not the right ones:
- Restarting effects less than they should could cause bugs in your app.
- Restarting effects more than they should could be inefficient.
As a rule of thumb, mutable and immutable variables used in the effect block of
code should be added as parameters to the effect composable. Apart from those,
more parameters can be added to force restarting the effect. If the change of
a variable shouldn't cause the effect to restart, the variable should be wrapped
in rememberUpdatedState
. If the variable never
changes because it's wrapped in a remember
with no keys, you don't need to
pass the variable as a key to the effect.
In the DisposableEffect
code shown above, the effect takes as a parameter the
backDispatcher
used in its block as any change to them should cause the effect
to restart.
@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
/* ... */
val backCallback = remember { /* ... */ }
DisposableEffect(backDispatcher) {
backDispatcher.addCallback(backCallback)
onDispose {
backCallback.remove()
}
}
}
backCallback
is not needed as a DisposableEffect
key because its value will
never change in Composition; it's wrapped in a remember
with no keys. If
backDispatcher
is not passed as a parameter and it changes, BackHandler
will
recompose but the DisposableEffect
won't dispose and restart again. That'll
cause problems as the wrong backDispatcher
will be used from that point
forward.
Constants as keys
You can use a constant like true
as an effect key to
make it follow the lifecycle of the call site. There are valid use cases for
it, like the LaunchedEffect
example shown above. However, before doing that,
think twice and make sure that's what you need.