Your composable ran four times. So did your network call.
A network call written directly in a composable fires an unpredictable number of times. LaunchedEffect, DisposableEffect and SideEffect each define a different notion of when work should happen.

Day 9 of 100. Day 7 established that composables re-run on Compose's schedule. Day 8 established that they enter and leave the composition. Today: what that means for any code that isn't purely describing UI.
The symptom
A detail screen loads its data:
@Composable
fun UserDetail(userId: String, repo: UserRepo) {
var user by remember { mutableStateOf<User?>(null) }
// Looks reasonable. Is not.
repo.fetchUser(userId) { user = it }
user?.let { UserCard(it) } ?: Spinner()
}
The network log shows four requests. Sometimes six. Change an unrelated piece of state on the screen and another one fires.
Worse, it may look fine in development and misbehave in production, because how often a composable re-runs depends on what else is on screen — which changes as the app grows.

Why the obvious fix fails
The obvious fix is a flag:
var loaded by remember { mutableStateOf(false) }
if (!loaded) {
repo.fetchUser(userId) { user = it }
loaded = true // writing state DURING composition
}
This is worse than the original, in a way that's hard to see.
Writing state during composition can invalidate the very scope you're in, which
schedules another composition, which may write again. Compose detects some of these and
throws; others just produce extra frames. And the flag is remembered per call site, so
if userId changes the screen keeps showing the first user's data — the flag says
"loaded", and nothing reconsiders.
You now have two bugs instead of one, and the second is intermittent.
The actual mechanism
A composable's body is a description, and Compose may run it whenever it likes: on state change, on parent recomposition, on a frame after being skipped, or twice in a row if something invalidates mid-composition. It may also abandon a composition part-way and discard the result.
So the body must be side-effect-free. Anything that changes the world outside the composition — a request, a database write, an analytics event, a listener registration — cannot live there.
Effects are the escape hatch, and the three you need first answer three different questions:
| Effect | Question it answers | Runs |
|---|---|---|
LaunchedEffect(key) |
"Do this suspending work while I'm on screen" | On enter, and again when key changes. Cancelled on leave. |
DisposableEffect(key) |
"Acquire something, and release it after" | On enter; onDispose on leave or key change |
SideEffect |
"Tell a non-Compose object about the current state" | After every successful composition |
LaunchedEffect is the one you want here, and the key is the whole point:
@Composable
fun UserDetail(userId: String, repo: UserRepo) {
var user by remember { mutableStateOf<User?>(null) }
LaunchedEffect(userId) { // re-runs iff userId changes
user = repo.fetchUser(userId)
}
user?.let { UserCard(it) } ?: Spinner()
}
One request on enter. No request on unrelated recompositions. A new request when
userId changes — and the previous coroutine is cancelled first, so a slow
response for the old user can't arrive late and overwrite the new one.
That cancellation is the part the flag version can never give you, and it's the difference between "loads data" and "loads data correctly".
Choosing the key is the whole skill
The key is not ceremony; it's the contract for when the work should restart.
LaunchedEffect(Unit) // once per enter, never again
LaunchedEffect(userId) // restart when the user changes
LaunchedEffect(a, b) // restart when either changes
Two failure modes, both common:
Key too narrow. LaunchedEffect(Unit) on a screen whose userId can change shows
stale data forever. The effect fired once; nothing tells it the world moved.
Key too wide. Passing an unstable object — a lambda, or a data class holding a
List — makes the key differ on every composition, so the effect restarts constantly.
A network call in a restart loop is a very expensive bug, and it looks like a backend
problem until you check.
If you need the effect to see a changing value without restarting on it,
rememberUpdatedState is the tool:
@Composable
fun AutoDismiss(onDismiss: () -> Unit) {
val currentOnDismiss by rememberUpdatedState(onDismiss)
LaunchedEffect(Unit) { // deliberately does NOT restart
delay(5_000)
currentOnDismiss() // but calls the LATEST lambda
}
}
Without it, either the timer restarts every time the parent passes a new lambda, or it calls a stale one. That's the whole reason the API exists.
The one that isn't an effect
For work triggered by a user action rather than by composition, none of these apply:
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { repo.save(draft) } }) { Text("Save") }
onClick is an ordinary lambda, already outside composition. It needs a scope tied to
the composition's lifetime, not an effect. Reaching for LaunchedEffect here — with a
boolean that flips on click — is a common early mistake that reintroduces the flag
problem from the top of this post.
The distinction: effects respond to composition; callbacks respond to users.
The effect that catches everyone once
SideEffect looks like the general-purpose one because of the name. It is the most
specialised of the three.
It runs after every successful composition, with no keys and no cancellation. Its only real job is publishing Compose state to an object that doesn't understand Compose:
@Composable
fun Screen(analytics: Analytics, screenName: String) {
SideEffect { analytics.setCurrentScreen(screenName) } // fine: idempotent setter
}
Putting a network call or an analytics event in there fires it on every
recomposition — the exact bug at the top of this post, wearing an effect's clothes.
The test: if running it twice in a row is harmful, SideEffect is the wrong tool.
Where the work actually belongs
Worth stating plainly, because the effects API can make it look like data loading is a UI concern. It usually isn't.
class UserViewModel(private val repo: UserRepo) : ViewModel() {
private val userId = MutableStateFlow<String?>(null)
val user: StateFlow<User?> = userId
.filterNotNull()
.mapLatest { repo.fetchUser(it) } // cancellation for free
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun show(id: String) { userId.value = id }
}
@Composable
fun UserDetail(vm: UserViewModel = viewModel()) {
val user by vm.user.collectAsStateWithLifecycle()
user?.let { UserCard(it) } ?: Spinner()
}
The composable now has no effect at all. Loading survives configuration changes,
mapLatest gives the same cancel-on-change semantics as the keyed LaunchedEffect,
and the whole thing is testable without a UI.
LaunchedEffect is right for work genuinely scoped to being on screen — starting an
animation, showing a snackbar, observing a sensor. For fetching, it's often a sign the
work is in the wrong layer. That distinction is Day 17.
How to prove it
Count executions:
@Composable
fun UserDetail(userId: String, repo: UserRepo) {
Log.d("Effects", "COMPOSE $userId")
LaunchedEffect(userId) { Log.d("Effects", "EFFECT $userId") }
…
}
Interact with unrelated state on the screen. COMPOSE lines appear repeatedly;
EFFECT lines appear once per distinct userId. If the ratio isn't roughly that, your
key is wrong — and this two-line diagnostic finds it faster than reading the code.
What this generalizes to
The underlying idea is separating description from execution. The composable
describes what the UI is; effects declare what should happen and when, with an explicit
restart contract. React's useEffect dependency array is the same design with the same
two failure modes — too narrow gives stale data, too wide gives loops.
Once you see the key as "the condition under which this work becomes invalid", picking it stops being guesswork.
Tomorrow, Day 10: the three phases — composition, layout, drawing — and why reading a value in the wrong one costs frames.
Day 9 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Side-effects in Compose.