Bites of Compose 7
All about side effect
Let’s talk about side effects in Compose.
Situation 1
What will happen when the button is clicked?
@Composable
fun Test() {
var flag by remember {
mutableStateOf(false)
}
Column {
Button(onClick = {
flag = !flag
}) {
Text("change")
}
Text(flag.toString())
Log.d("test", "flag is $flag")
}
}
Answer
The Text
on the screen will change, also there will be a log entry in logcat.
This logging behaviour is something called a side-effect
in compose world.
In general, it is not recommended to write side-effect directly inside Composables
, because in each recomposition round,
the code might be executed multiple times or even half a time (canceled half way). So in this case, we might get multiple loggings.
Situation 2
What will happen if button is clicked?
@Composable
fun Test() {
var flag by remember {
mutableStateOf(false)
}
Column {
Button(onClick = {
flag = !flag
}) {
Text("change")
}
Text(flag.toString())
SideEffect {
Log.d("test", "flag is $flag")
}
}
}
Answer
The same as previous. But it is guaranteed to be executed once per recomposition.
Situation 3
What will happen if button is clicked?
@Composable
fun Test() {
var flag by remember {
mutableStateOf(false)
}
Column {
Button(onClick = {
flag = !flag
}) {
Text("change")
}
if (flag) {
DisposableEffect(Unit) {
Log.d("test", "enter screen")
onDispose {
Log.d("test", "leave screen")
}
}
Text("Show me")
}
}
}
Answer
When Text
is shown, “enter screen” is logged, and when it is hidden, “leave screen” is logged.
So basically DisposableEffect
is very similar to SideEffect
, only difference is that it can also detect when a composable is going off screen.
Situation 4
What will happen if button is clicked?
@Composable
fun Test() {
var flag by remember {
mutableStateOf(false)
}
Column {
Button(onClick = {
flag = !flag
}) {
Text("change")
}
DisposableEffect(flag) {
Log.d("test", "enter screen")
onDispose {
Log.d("test", "leave screen")
}
}
}
}
Answer
“leave screen” is logged first, then “enter screen” will be logged.
Here we are looking at the key
param, how it works is that when the key
changed, the DisposableEffect
will be restarted.
The restart means:
- The onDispose callback will be first called.
- The enter screen callback will be called.
This ordering is useful, e.g. if we have some cleanup code inside onDispose, it will guarantee that the cleanup code will always be called first.
Situation 5
Let’s test our understanding a bit by the following example, please try to describe what will be logged when the app is firstly launched and also when the button is clicked.
@Composable
fun Test() {
var flag by remember {
mutableStateOf(false)
}
Column {
Button(onClick = {
flag = !flag
}) {
Text("change")
}
if (flag) {
Text("show me")
}
SideEffect {
Log.d("test", "enter screen: from SideEffect")
}
DisposableEffect(Unit) {
Log.d("test", "enter screen: from DisposableEffect")
onDispose {
Log.d("test", "leave screen: from DisposableEffect")
}
}
}
}
Answer
When the app first launched:
enter screen: from DisposableEffect
enter screen: from SideEffect
After clicking the button:
enter screen: from SideEffect
Note that the logs from DisposableEffect
are not triggered when button is clicked.
Situation 6
What will happen when the app is launched?
@Composable
fun Test() {
var flag by remember {
mutableStateOf(false)
}
Column {
LaunchedEffect(key1 = Unit) {
delay(3000)
flag = !flag
}
if (flag) {
Text("show me")
}
}
}
Answer
Easy, the Text
will show after 3 seconds.
Here we used the LaunchedEffect
, which works similarly to DisposableEffect
except that it will start a coroutine for the code to run into.
Situation 7
What will be logged?
@Composable
fun Test() {
var name by remember {
mutableStateOf("Unknown")
}
Button(onClick = { name = "Bob" }) {
Text("change")
}
Column {
LaunchedEffect(key1 = Unit) {
delay(3000)
Log.d("test", name)
}
}
}
Answer
Well, if nothing is done, then “Unknown” will be logged. But if you are quick enough to click the button, then “Bob” will be logged.
We also observe here that when name
got changed, the LaunchedEffect
block will not be recomposed, this is different
from a normal Composable function. Well, it kind of makes sense, because the code block inside LaunchedEffect
will be saved later to be executed inside coroutine,
so it is not really UI code that needs to be refreshed immediately.
Situation 8
Now let’s extract out a function for the LaunchedEffect
part and will things change?
@Composable
fun Test() {
var name by remember {
mutableStateOf("Unknown")
}
Button(onClick = { name = "Bob" }) {
Text("change")
}
CustomLaunchedEffect(name = name)
}
@Composable
private fun CustomLaunchedEffect(name: String) {
LaunchedEffect(Unit) {
delay(3000)
Log.d("test", name)
}
}
Answer
It’s not working anymore, even after click the button, “Unknown” is still logged.
Why is that?
Because name is a State
, but if we pass it to a function it will reduce to a normal string inside the function. After the click, the CustomLaunchedEffect
will get recomposed, but the LaunchedEffect
inside it will not (because of the Unit key). So the String object hold by LaunchedEffect
now is different with the one in the param of CustomLaunchedEffect
, thus not able to log the correct value.
Situation 9
Let’s see if we can fix this problem, how about now?
@Composable
private fun CustomLaunchedEffect(name: String) {
val remembered = remember(name) {
mutableStateOf(name)
}
LaunchedEffect(Unit) {
delay(3000)
Log.d("test", remembered.value)
}
}
Answer
Nope, still not working. But why? We have already created a State
out of the name
, this is exactly like when we do not have CustomLaunchedEffect
yet, right?
Wrong, look careful, we use remember(name)
here, so what happens is this:
-
When the app launched,
CustomLaunchedEffect
is called withname = Unknown
. -
remember(name)
will create aState
object with value of “Unknown” and pass it toLaunchedEffect
. -
User clicks the
Button
, nowCustomLaunchedEffect
is called with a new value “Bob”. -
Since “Bob” != “Unknown”,
remembered
will be assigned a newState
object with value of “Bob”. -
LaunchedEffect
will not be reached because it has the samekey = Unit
, hence theremembered
State
object it holds is still the old one. See?
Situation 10
OK, so how to actually fix that?
Answer
So the key to solve this problem is to have the “same object” shared between CustomLaunchedEffect
and LaunchedEffect
.
@Composable
private fun CustomLaunchedEffect(name: String) {
val remembered = remember {
mutableStateOf(name)
}
remembered.value = name
LaunchedEffect(Unit) {
delay(3000)
Log.d("test", remembered.value)
}
}
This slightly weird looking code is the proper solution.
Hmm, interesting, this seems to be a unique problem that only LaunchedEffect
has (and only in the case of inside a function).
Actually, there is a helper function rememberUpdatedState
that does exactly the same thing. So the above code can be simplified as:
@Composable
private fun CustomLaunchedEffect(name: String) {
val remembered by rememberUpdatedState(newValue = name)
LaunchedEffect(Unit) {
delay(3000)
Log.d("test", remembered)
}
}
Share this post
Twitter
Google+
Facebook
Reddit
LinkedIn
StumbleUpon
Email