Posted By: Udit Verma
Other posts in this series:
Part II. Jetpack Compose Side-Effects — rememberCoroutineScope
The dictionary defines the term side-effect as an undesirable effect. While this applies to compose side-effects as well, sometimes they are required to mutate the state of the app.
Side effects are undesirable because they can potentially change the state of the app outside the scope of the composable (global state). This basically means that the composable function might not behave the exact same way when called multiple times. Since a composable function can be executed multiple times or can skip execution altogether, mutating or relying on global state in composable functions can lead to unforeseen errors or bugs. Hence relying on side-effects is not the recommended approach.
For cases when side-effects are necessary, Compose has a bunch of methods to execute them in a controlled environment. This way, we can control the lifecycle of these side-effects so that they do not live longer than required and are cleaned up once no longer required.
In this series of blog posts, we will try to understand what different ways can we perform a side-effect in Compose. This first part talks about LaunchedEffect.
LaunchedEffect — Launch a coroutine tied to the scope of the composable.
We can use LaunchedEffect to perform actions which are tied to the lifecycle of the composable. If the composable exits composition, or in other words, is no longer being displayed on the screen, the coroutine will cancel itself avoiding any memory or process leaks.
For example, let us say we want to start a timer as soon as the composable enters composition and stop it when it leaves composition, even if the timer has not ended. For this use case, we can start a timer inside the LaunchedEffect block and not worry about stopping or cleaning up timer related code when the composable leaves composition.
@Composable | |
fun TimerScreen() { | |
LaunchedEffect(key1 = Unit, block = { | |
try { | |
startTimer(5000L) { // start a timer for 5 secs | |
println("Timer ended") | |
} | |
} catch(ex: Exception) { | |
println("timer cancelled") | |
} | |
}) | |
} | |
suspend fun startTimer(time: Long, onTimerEnd: () -> Unit) { | |
delay(timeMillis = time) | |
onTimerEnd() | |
} |
In the example above, inside LaunchedEffect block, we call a suspend function startTimer
. The timer prints on the console if it completes, or is cancelled before it could complete.
Before going further, let’s quickly look at the api for LaunchedEffect. It takes in two parameters, key1
and block
.
Block is pretty straightforward — it the lambda which runs inside the coroutine scope tied to this composable.
key1 is a parameter which tells LaunchedEffect to relaunch the coroutine and cancel the current one whenever its value changes. Here we have passed Unit to avoid re-launching the coroutine based on this value. More on this later!
Let’s take a look at a few different scenarios:
When the composable is first launched, the timer starts. After 5 seconds, it prints on the console “Timer ended” as expected.
Now let’s look at how is this tied to the lifecycle of the composition. We know that if we rotate the screen on Android, the activity is recreated and so TimerScreen
will be recomposed. Now, if you rotate the device before the timer has ended (before 5 seconds in this case), you will notice the message “Timer cancelled” printed on the console. 5 seconds after this message is printed, you’ll see the message “Timer Ended” printed as well.
What happened in the above case was when we rotated the screen, the TimerScreen()
composable exited the composition cancelling the coroutine automatically and hence the cancellation message was printed. After rotation completed, the TimerScreen()
was launched again starting a new timer which printed the “Timer ended” message on completion.
To understand the usage of key1 parameter, let’s look at a slightly different example where user can increment or decrement the timer values.
@Composable | |
fun TimerScreen1() { | |
Column( | |
modifier = Modifier | |
.fillMaxSize(), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
var timerDuration by remember { | |
mutableStateOf(1000L) // default value = 1 sec | |
} | |
Button({ | |
timerDuration -= 1000 | |
}) { | |
Text("-1 second") | |
} | |
Text(timerDuration.toString()) | |
Button({ | |
timerDuration += 1000 | |
}) { | |
Text("+1 second") | |
} | |
Timer(timerDuration = timerDuration) | |
} | |
} | |
@Composable | |
fun Timer(timerDuration: Long) { | |
LaunchedEffect(key1 = timerDuration, block = { | |
try { | |
startTimer(timerDuration) { | |
println("Timer ended") | |
} | |
} catch (ex: Exception) { | |
println("timer cancelled") | |
} | |
}) | |
} |
Here, the TimerScreen1()
has a column of 2 buttons to increment or decrement timer value by a second and a text to display the current value.
In the Timer
composable, we are now passing this value which is used by the startTimer
function to decide the duration of the timer.
Now if you notice, we have passed this same value to key1
as well. This basically means that relaunch this coroutine whenever the value of timerDuration changes cancelling the previous coroutine.
Let’s understand this better with a scenario. The user launches the screen, starting a timer with the default value of (1 sec). After one second we the message “Timer ended”. Now, let’s say the user presses the +1 second button two times in quick succession. The value will update to 3 seconds. On the console, you will see “Timer cancelled” followed by “Timer Ended”.
Job Offers
Initially, the timer ran for 1 second, printing the first “Timer ended” message as expected. What happened next was, on the tap of +1 second
button the first time, value of timerDuration
changed to 2, re-triggering the LaunchedEffect block. Before this 2 second timer could complete, the timerDuration
value again changed to 3 as the user again tapped the button. Since this value is passed in key1
parameter to LaunchedEffect
, the currently running timer is cancelled printing the cancellation message. A new timer with delay value as 3 seconds is started. This timer completes printing the second success message.
Hopefully, the above examples have made usage of LaunchedEffect clearer to you. Clap for yourself (and this post) for learning something new today 😀. In the coming posts, we will look at other ways to launch side effects.
Till then, keep composing and don’t forget to follow for more parts to come.