Side Effects Summary in Jetpack Compose
Do you find side effects in Jetpack Compose confusing? I do. So, I document the summary of using side effects here for my future reference.
A side effect in Compose is a change to the state of the app that happens outside the scope of a composable function.
To handle these side effects, you can use effect handlers. There are 2 types of effect handlers.
Suspended Effect Handler | Non-suspended Effect Handler |
LaunchedEffect() | DisposableEffect() |
rememberCoroutineScope() | SideEffect() |
Suspended Effect Handler
A suspended effect handler allows you to perform side effects in compose using coroutines.
Here is the summary that covers different scenarios:
Scenarios | LaunchedEffect() | rememberCoroutineScope() |
Launch effect/coroutine from within a composable? | Yes | No |
Launch effect/coroutine outside a composable? E.g. from the callback | No | Yes |
When effect/coroutine is started? | LaunchedEffect() enters composition | CoroutineScope.launch() is called |
When effect/coroutine is canceled? | LaunchedEffect() leaves composition, LaunchedEffect() is restarted (effect's key changed) | CoroutineScope leaves composition. Note: When coroutine is restarted, the previous coroutine will NOT be canceled |
When effect/coroutine is restarted? | LaunchedEffect() 's key changed (while it is still in composition) | CoroutineScope.launch() is called again |
Non-suspended Effect Handler
For non-suspended/non-coroutine side effects, you can use these non-suspended effect handlers.
Scenarios | DisposableEffect() | SideEffect() |
Launch effect from within a composable? | Yes | Yes |
Launch effect outside a composable? E.g. from the callback | No | No |
When effect is started? | DisposableEffect() enters composition, after the current composition completes | SideEffect() enters composition, after the current composition completes |
When effect is canceled? | Effect can NOT be canceled, since the execution can NOT be suspended(non-suspended function) | Effect can NOT be canceled - same as DisposableEffect() |
When effect is restarted? | DisposableEffect() 's key changed (while it is still in composition) | SideEffect() enters recomposition - every recomposition triggers the SideEffect() to run |
When onDispose() is called? | DisposableEffect() leaves composition, DisposableEffect() is restarted (effect's key changed) Note: When the effect completes, it will NOT trigger onDispose() | N/A |
Various Side-effect States
These are the various compose state helpers for effect handlers above.
rememberUpdatedState
rememberUpdatedState
makes sure the MutableState
is always updated with the latest value instead of caching the initial composition value.
See notes (1), (2), (3), (4) and (5) below.
@Composable
fun RememberUpdatedStated(value: String) {
val oldValue by remember { mutableStateOf(value) }
val newValue by rememberUpdatedState(value)
// (2) LaunchedEffect is skipped during the second recomposition
// when value is changed/updated
LaunchedEffect(true) {
// (1) let's assume value is updated with a new value within 1 second delay
delay(1000)
// Access value, oldvalue and newValue here
// (3) value is the initial value when LaunchedEffect is first called
// (4) oldValue is the initial value from first composition
// (5) newValue is the new value from second recomposition
}
}
Only the
newValue
has the latest updatedvalue
from the second recomposition
If you look at the rememberUpdatedState
implementation, it applies the new value to the MutableState
whenever it is called or during recomposition.
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
Technically,
val newValue by rememberUpdatedState(value)
is equivalent to
var newValue by remember { mutableStateOf(value) }
newValue = value
produceState
produceState
is the sugar syntax for LaunchedEffect()
.
LaunchedEffect()
below can be written as
@Composable
fun DemoLaunchedEffect(){
var text by remember { mutableStateOf("")}
LaunchedEffect(true) {
repeat(10) { count ->
delay(1000)
text = count.toString()
}
}
}
produceState()
below:
@Composable
fun DemoProduceState(){
val text by produceState(initialValue ="") {
repeat(10) { count ->
delay(1000)
value = count.toString()
}
}
}
One small difference of produceState
is it produces State<T>
instead of MutableState<T>
. Thus, you see the text
above is declared with val
instead of var
.
One additional thing produceState
can do is awaitDispose()
function, which allows you to detect when the produceState
leaves the composition. This is similar to onDispose()
from DisposableEffect()
@Composable
fun DemoProduceStateAwaitDispose(){
val text by produceState(initialValue ="") {
val job = MainScope().launch {
repeat(10) { count ->
delay(1000)
value = count.toString()
}
}
awaitDispose {
job.cancel()
}
}
}
However, to use
awaitDispose()
you MUST manually launch a coroutine with aCoroutineScope
. The following example uses theMainScope()
.
Without the MainScope()
or any CorutineScope
, the awaitDispose()
will not be called. For example, the following code won't work.
@Composable
fun DemoProduceStateAwaitDispose(){
val text by produceState(initialValue ="") {
repeat(10) { count ->
delay(1000)
value = count.toString()
}
// Won't work - awaitDispose() won't be called
awaitDispose {
job.cancel()
}
}
}
For some reason, this requirement is not documented. It takes me a while to figure out the
awaitDispose()
requires you to launch the coroutine manually to get it working correctly.
derivedStateOf
Not sure why this is categorized under side effects, but what derivedStateOf()
does, is combining multiple states into a single State
.
@Composable
fun DemoDerivedStateOf() {
var value1 by remember { mutableStateOf(true) }
var value2 by remember { mutableStateOf(false) }
val derivedValue by remember(value1) {
derivedStateOf {
"value1: $value1 + value2: $value2"
}
}
}
When value1
or value2
is changed, derivedValue
is updated. The remember(value1)
is needed so that, during recomposition, derivedStateOf()
is skipped.
If you remove the remember(value1)
as the following, everything still works correctly.
@Composable
fun DemoDerivedStateOf() {
var value1 by remember { mutableStateOf(true) }
var value2 by remember { mutableStateOf(false) }
val derivedValue by derivedStateOf {
"value1: $value1 + value2: $value2"
}
}
However, during every recomposition, this line "value1: $value1 + value2: $value2"
is executed. Thus, it simply wastes unnecessary resources here.
snapShotFlow
snapShotFlow
converts State<T>
to Flow<T>
. Here is an example:
@Composable
fun DemoSnapShotFlow(){
var textState = remember { mutableStateOf("") }
LaunchedEffect(textState) {
// Convert State<T> to Flow<T>
val flow = snapshotFlow { textState.value }
// Ensure flow doesn't emit the same value twise
flow.distinctUntilChanged()
// Collect the flow
flow.collect { text ->
Log.d("[SnapShotFlow]", "collecting flow value: $text")
}
}
}
For more information about collecting flow, refer to the following article:
Conclusion
I just merely document these side effect handlers' usages and their behaviors. However, I haven't known how to use them effectively yet. Sometimes I do find it confusing which one to use. :)
Source Code
This is my experimental code of playing around with side effects in Jetpack Compose. So it might be a bit messy here.
GitHub Repository: Demo_ComposeSideEffects