跪拜 Guibai
← Back to the summary

Compose Effects Are Just remember() With a Lifecycle Hook


theme: smartblue highlight: xcode

ChatGPT Image 2026年7月28日 22_10_19 (1).png

Hello, Compose enthusiasts.

If you open the source code of any Compose screen, you will almost always see various Effect APIs.

We probably write code like this ourselves:

These Effect APIs are used so frequently, but have you ever wondered: what exactly do they do internally? And what is the cost of doing it this way?

1.png

Most developers understand LaunchedEffect as: when the key changes, re-execute this block of code.

This understanding is not wrong, but it doesn't explain what the Runtime actually does.

Every time the key changes, Compose cancels a running coroutine and then starts a brand new one. Behind every LaunchedEffect there is a real CoroutineScope, a Job, and a coroutine — even if the block passed in never suspends.

Suppose the Effect contains only a single synchronous call, and you just want it to execute when the key changes. In that case, we are actually creating a coroutine for a task that never needed one.

To understand why this happens, and when you should switch to a lighter-weight alternative, we need to dig deeper into these Effect APIs.

In the end, you will find that most of them are built on top of a single interface: RememberObserver.

This article will start from RememberObserver and look at the following questions in turn:

Executing Imperative Code in a Declarative UI

Composition is declarative and allows repeated execution. A Composable function might execute once, then again on the next recomposition, and continue executing afterward — there is no fixed number of calls.

Using it only to describe UI is fine, but problems arise when you need to execute imperative tasks that must have a controlled execution count.

For example, we could directly trigger a load inside a Composable:

@Composable
fun UserScreen(userId: String, viewModel: UserViewModel) {
    viewModel.load(userId) // Executes on every recomposition
    val user by viewModel.user.collectAsStateWithLifecycle()
    UserContent(user)
}

Whenever UserScreen recomposes, viewModel.load(userId) executes again. If an animation is playing elsewhere on the screen, it could even be called dozens of times per second.

We definitely don't want this code to execute like that. What we really want is: load only once per userId, not on every recomposition.

Of course, this also exposes another problem: where should the cleanup logic go? If the screen leaves the composition tree, we have no opportunity to cancel an ongoing request.

The role of the Effect API is to give imperative code a well-defined position within the Composition lifecycle. It solves three questions that direct calls cannot answer:

  1. How many times should this code execute?
  2. When should it be cleaned up?
  3. If this Composition is discarded before being committed, what should happen to the scheduled tasks?

These questions all ultimately fall on the same set of mechanisms. Let's start by looking at the entry point of this mechanism.

The Four Effect APIs

There are four commonly used Effect APIs in Compose:

The underlying structure of three of these APIs is almost identical: call remember to create an object that implements RememberObserver, and the real lifecycle logic is written inside this interface.

public interface RememberObserver {
    public fun onRemembered()
    public fun onForgotten()
    public fun onAbandoned()
}

The interface is small. Let's look at it first:

Its KDoc also provides two important guarantees:

  1. After an object is remembered at a single location, it will receive exactly one of onRemembered or onAbandoned, never both.
  2. When multiple objects are remembered together, the onForgotten calls happen in the reverse order of the onRemembered calls.

SideEffect is the exception here; we will analyze it separately later.

The remaining three APIs can all be summarized with the same idea: use remember to place a RememberObserver into the Slot Table, then use the three callbacks to perform the actual work.

I need to specifically explain onAbandoned here, as it can be confusing:

What does that mean? Although it sounds a bit harsh, using a job onboarding/offboarding analogy might be the most fitting:

In other words, if onRemembered has already happened (you joined), then the only subsequent path is onForgotten (leaving). Of course, for something that hasn't yet onRemembered, the only path is onAbandoned (the position was withdrawn before you signed the contract).

So, onAbandoned and the onRemembered + onForgotten sequence are actually mutually exclusive!

remember is the True Foundation

If you look at the source code, you'll find that the Effect functions themselves do almost nothing.

Below is the complete implementation of LaunchedEffect(key1):

@Composable
@NonRestartableComposable
public fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

Only two steps:

  1. Obtain a coroutine context;
  2. Call remember(key1) to create and save a LaunchedEffectImpl.

The behavior we usually attribute to LaunchedEffect, including "restarting when the key changes", actually comes from remember(key1).

So the question becomes: when the key changes, what exactly does remember do?

Under the hood, remember calls Composer.cache. It first reads the value stored in the current Slot, then decides whether to continue using the old value or recalculate:

public inline fun <T> Composer.cache(
    invalid: Boolean,
    block: () -> T
): T {
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

remember(key1) { ... } passes the result of currentComposer.changed(key1) as the invalid parameter. If the current key differs from the previously saved key, invalid is true.

The entire judgment can be summarized as:

After the key changes, remember creates a completely new object, and the object originally stored in the Slot is scheduled to leave the Composition.

This point is crucial.

A key change does not simply re-execute the original Effect in place.

More precisely, the Runtime creates a new RememberObserver and simultaneously forgets the old RememberObserver. The behavior the Effect ultimately exhibits depends on what the old and new objects do in onRemembered and onForgotten, respectively.

Dissecting LaunchedEffect: The Coroutine Hidden Behind It

Now let's look at the object remember saves.

LaunchedEffectImpl implements RememberObserver, and the coroutine appears precisely within its callbacks:

internal class LaunchedEffectImpl(
    private val parentCoroutineContext: CoroutineContext,
    private val task: suspend CoroutineScope.() -> Unit,
) : RememberObserver, CoroutineExceptionHandler {
    private val scope = CoroutineScope(parentCoroutineContext + this)
    private var job: Job? = null

    override fun onRemembered() {
        job?.cancel("Old job was still running!")
        job = scope.launch(block = task)
    }

    override fun onForgotten() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }

    override fun onAbandoned() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }
}

This class creates a CoroutineScope during construction and holds a nullable Job internally.

When it is remembered by the Composition, onRemembered launches the task within this Scope and saves the returned Job. When it is forgotten or abandoned, it cancels the Job using LeftCompositionCancellationException and then clears the reference.

There is also a line inside onRemembered:

job?.cancel("Old job was still running!")

This is a defensive measure. Under normal circumstances, a newly created LaunchedEffectImpl has not yet launched any task, so job should be null, meaning this line usually does nothing.

This class also implements CoroutineExceptionHandler and places itself into the Scope's context. The source code explains that this avoids creating a separate Handler object, saving one object allocation.

Combining this logic with the remember section above, the logic when the key changes becomes immediately clear.

Suppose we write:

LaunchedEffect(userId) {
    load(userId)
}

Now userId changes from A to B:

  1. During recomposition, remember(B) compares the saved key A with the new key B. They are different, so it executes the factory, creating a new LaunchedEffectImpl with its own Scope; the old Impl is scheduled to enter the forgotten flow.
  2. After the Composition enters the apply phase, the Runtime first calls onForgotten on the old Impl, canceling the coroutine still executing load(A); then it calls onRemembered on the new Impl, launching a new coroutine to execute load(B).

So, the true meaning of "the Effect restarts after the key changes" at the low level is:

Cancel the old coroutine, then start a new coroutine.

The entire process is driven by remember creating different object instances. The LaunchedEffect function itself doesn't even directly check the key.

When the screen completely leaves the composition tree, only onForgotten happens: the coroutine is canceled, and no new task will be started. This is why a running LaunchedEffect can automatically stop when its Composable disappears.

Where the Job Comes From

LaunchedEffectImpl creates its Scope using parentCoroutineContext + this, but it doesn't add a Job() itself.

So, what acts as the parent Job for the ultimately launched coroutine?

Why can we directly call withFrameNanos or various animate* APIs inside a LaunchedEffect without any extra configuration?

The answer lies in the context that LaunchedEffect obtains from currentComposer.applyCoroutineContext.

This context is assembled by the Recomposer. On top of the externally provided Effect Context, it adds two more elements:

override val effectCoroutineContext: CoroutineContext =
    coroutineContext + broadcastFrameClock + effectJob

broadcastFrameClock is a MonotonicFrameClock, and effectJob is the parent Job for all Effect tasks.

Since applyCoroutineContext already contains a Job, the standard CoroutineScope(context) factory won't add an extra one. Therefore, the Effect coroutine's Job becomes a child of Recomposer.effectJob.

This produces two results:

  1. When the entire Recomposer is canceled — typically meaning the Composition is being disposed — all Effect coroutines are canceled together via the parent-child Job relationship.
  2. The frame clock is also in the same coroutine context, so withFrameNanos can find it directly:
    public suspend fun <R> withFrameNanos(
        onFrame: (frameTimeNanos: Long) -> R
    ): R = coroutineContext.monotonicFrameClock.withFrameNanos(onFrame)
    

This is why animations written inside a LaunchedEffect can naturally synchronize with screen refreshes. The Job responsible for cancellation and the Frame Clock responsible for providing frame times are both passed in through the same context.

The Hidden Cost

If the Effect executes an inherently asynchronous task, then the mechanisms described above are exactly what we need.

Collecting a Flow, calling a suspend network request, executing a delay, or running an animation all require a coroutine, a Job for cancellation, and a frame clock.

LaunchedEffect prepares all of these things.

But what if the block never suspends?

For example, the following pattern is common:

LaunchedEffect(count) {
    analytics.log("count changed to $count") // Synchronous call, never suspends
}

Our requirement is simply: every time count changes, execute this line of code once.

But what we actually get is: a LaunchedEffectImpl and a CoroutineScope are created; every time the key changes, a coroutine is canceled and a new one is started, just to execute a single line of synchronous code.

After the coroutine starts, the block runs from beginning to end without suspending once, and then the coroutine finishes. The entire set of mechanisms in front of it played no role.

A lighter-weight approach is to use a RememberObserver that executes the block directly in onRemembered, without creating a Scope or needing a Job.

Here is the simplest possible implementation:

@Composable
@NonRestartableComposable
public fun RememberedEffect(vararg keys: Any?, effect: () -> Unit) {
    remember(*keys) { RememberedEffectImpl(effect = effect) }
}

/**
 * Launches the provided [effect] lambda when it enters the composition.
 */
internal class RememberedEffectImpl(private val effect: () -> Unit) : RememberObserver {

    override fun onRemembered() {
        effect.invoke()
    }

    override fun onAbandoned() {
        // no-op
    }

    override fun onForgotten() {
        // no-op
    }
}

Its usage is almost identical to LaunchedEffect:

var count by remember { mutableIntStateOf(0) }

RememberedEffect(count) {
    Log.d(tag, "$count")
}

Internally, RememberedEffect still uses remember(key1) to save a RememberObserver and executes the block inside onRemembered. The handling of key changes is completely unchanged; only the coroutine has been removed.

This allows you to choose based on the following rules:

In fact, Compose Runtime itself provides an Effect that does not depend on coroutines, which is the DisposableEffect we will discuss next, though it is underutilized in many projects.

DisposableEffect: No Coroutine Dependency

DisposableEffect has existed in Compose Runtime for a long time, and it does not need a coroutine itself.

Its Impl is also a RememberObserver, but the callbacks execute regular code:

private class DisposableEffectImpl(
    private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
    private var onDispose: DisposableEffectResult? = null

    override fun onRemembered() {
        onDispose = InternalDisposableEffectScope.effect()
    }

    override fun onForgotten() {
        onDispose?.dispose()
        onDispose = null
    }

    override fun onAbandoned() {
        // onRemembered was not executed, so no cleanup is needed
    }
}

When the object is remembered, onRemembered executes the effect we passed in and saves the DisposableEffectResult it returns. When the object is forgotten, onForgotten calls dispose() on that result.

The main role of DisposableEffectScope is to allow onDispose { } to return a DisposableEffectResult:

public class DisposableEffectScope {
    public inline fun onDispose(
        crossinline onDisposeEffect: () -> Unit
    ): DisposableEffectResult =
        object : DisposableEffectResult {
            override fun dispose() {
                onDisposeEffect()
            }
        }
}

Note:

  1. There is no coroutine in the entire flow. Therefore, for scenarios requiring paired registration and unregistration of Listeners, Observers, or Callbacks, where the lifecycle depends on a certain key, DisposableEffect is the more appropriate choice.
  2. DisposableEffectImpl.onAbandoned does nothing (because there is no asynchronous issue); whereas in LaunchedEffectImpl, onAbandoned still attempts to cancel the Job.

This is because DisposableEffectImpl only acquires resources in onRemembered. If the Effect was abandoned before being remembered, there are naturally no resources to release.

The LaunchedEffectImpl canceling the Job in onAbandoned is more of a defensive measure. At that point, job is usually also null, but performing a safe call on a null Job has no side effects and keeps the callback itself safe.

However, DisposableEffect has a minor usability issue: even if there is no cleanup logic at all, you must still return an onDispose { }.

If you just want to execute synchronous code when a key changes, you are forced to write an empty onDispose { }. The dedicated RememberedEffect fills exactly this gap: it has the same coroutine-free key responsiveness but does not require you to pad it with meaningless cleanup code.

SideEffect: A Slightly Different Effect

SideEffect follows a completely different pattern from all the previous implementations.

It is not a RememberObserver, and it does not save any object in the Slot Table:

@Composable
@NonRestartableComposable
public fun SideEffect(effect: () -> Unit) {
    currentComposer.recordSideEffect(effect)
}

There is no SideEffectImpl in the source code.

recordSideEffect simply adds the block to the Composition's Change List, to be executed during the apply phase after all Remember callbacks have finished.

Because no object is remembered, SideEffect has no object identity, no key, and no cleanup callback. It executes whenever the code containing it participates in a Composition and that result is successfully committed.

It is suitable for publishing values from Compose to external objects, requiring the external object to stay in sync with Compose after every commit. For example, writing a property to a View or a system service.

The differences from the previous two implementations are as follows:

If you have ever wondered why SideEffect has no cleanup logic and doesn't depend on a key like other Effects, the reason is here: it never enters the Slot Table.

In one sentence: SideEffect executes after every successful commit of the Composition containing it, including the initial composition and subsequent recompositions.

rememberCoroutineScope: A Scope We Drive Ourselves

The last API returns a Coroutine Scope but does not automatically launch any tasks into it.

Its definition is a remember without a key:

@Composable
public inline fun rememberCoroutineScope(
    crossinline getContext: () -> CoroutineContext = {
        EmptyCoroutineContext
    }
): CoroutineScope {
    val composer = currentComposer
    return remember {
        createCompositionCoroutineScope(getContext(), composer)
    }
}

Because there is no key, this Scope is created once during the first Composition and persists until the call site leaves the composition tree.

The returned object is a RememberedCoroutineScope.

It implements both CoroutineScope and RememberObserver. The internal coroutine context is created only on first access, so if we obtain the Scope but never launch a coroutine, it incurs almost no overhead. Its onForgotten and onAbandoned will cancel any created content.

Its Job is also a child of the Job in the apply context, so it is canceled when the Composition is disposed.

The key point of rememberCoroutineScope is: it does not launch coroutines by itself.

We need to actively call scope.launch { } from an event callback like onClick to start a coroutine. Event callbacks happen outside the composition process, so we cannot directly use the Composition to start and manage that work.

LaunchedEffect is different; it treats launching the coroutine itself as a side effect of Composition.

So, the difference between these two APIs is who triggers the task and what lifecycle it is bound to:

The Composition Lifecycle: What We Really Need to Know

ChatGPT Image 2026年7月28日 22_10_19 (2).png

Now we know what each Effect does inside the RememberObserver callbacks.

But there is still one question: are these callbacks triggered immediately when the Composable executes to the corresponding position?

They are not.

Composition is responsible for calculating what the UI should look like, and the Runtime first records the changes produced by this calculation. Only when the result is successfully committed does it dispatch the corresponding lifecycle callbacks.

This is also why Effects cannot be written as plain function calls. Just because a Composable executed does not mean this particular calculation will become the final UI. If the Runtime is still calculating and we register listeners, start coroutines, or modify external objects, the side effects would have already occurred even if this result is ultimately discarded.

Therefore, the RememberObserver callbacks can be simplified into two mutually exclusive paths.

The first path is when the object successfully enters the Composition:

onRemembered()
    ↓
onForgotten()

onRemembered indicates the object has been successfully remembered. Later, when the key changes, the call site leaves the composition tree, or the entire Composition is disposed, it will receive onForgotten.

The second path is when the object was created, but this Composition was not successfully committed:

onAbandoned()

In this case, the object never truly entered the Composition, so it will not receive onRemembered, nor will it ever receive onForgotten.

In other words, for a RememberObserver created at the same location, it typically either experiences:

onRemembered → onForgotten

or only experiences:

onAbandoned

onRemembered → onAbandoned will not occur.

The "apply thread" mentioned multiple times here emphasizes "the thread that applies Composition changes and dispatches lifecycle callbacks." It does not mean Compose creates a fixed background thread named "apply."

SideEffect does not participate in this remember and forget lifecycle. It is simply recorded separately and executed after the current Composition is successfully committed and the Remember callbacks have been processed.

For understanding Effects, knowing this is enough.

A Complete Walkthrough: What Happens During a Single Key Change

Now let's return to LaunchedEffect:

LaunchedEffect(userId) {
    load(userId)
}

Suppose userId changes from A to B.

During recomposition, remember(B) finds that the key has changed, so it creates a new LaunchedEffectImpl. The old object corresponding to A is scheduled to leave the Composition.

If this Composition is successfully committed, two things happen next:

  1. The old object receives onForgotten(), and the running load(A) coroutine is canceled;
  2. The new object receives onRemembered(), and a new coroutine is launched to execute load(B).

The entire process can be compressed as follows:

userId: A → B
        ↓
remember detects key change
        ↓
Old LaunchedEffectImpl leaves
New LaunchedEffectImpl enters
        ↓
Old object onForgotten: cancels load(A)
New object onRemembered: launches load(B)

So, LaunchedEffect does not re-execute the original block in place; it ends the old Effect's lifecycle and creates a new Effect.

If this Composition is not successfully committed, the situation is different. The newly created object only receives onAbandoned() and will not start a new coroutine; the previously existing old object will also not be replaced because of an uncommitted result.

The other APIs can also be understood within this model:

Common Pitfalls in Practical Development

ChatGPT Image 2026年7月28日 22_10_19 (3).png

At this point, the underlying logic of Effects is clear. Returning to daily development, there are a few more pitfalls worth highlighting.

The Key Determines Whether the Runtime Considers It "the Same Effect"

We often say "the Effect restarts when the key changes," but the key is not just an ordinary parameter; it actually participates in determining the Effect's identity.

Multiple keys collectively form this identity condition. As soon as any one of them is judged to have changed, the Runtime forgets the old object, then creates and remembers a new one.

Therefore, do not casually use objects or lambdas that produce a new identity on every recomposition as a key. Otherwise, the Effect might be canceled and restarted on every recomposition.

However, "creating a new object" does not necessarily mean the key has changed. Compose checks whether the old and new keys are the same via equality. If two old and new objects have equal semantics, such as a data class with identical content, they may not trigger an Effect restart.

What truly needs to be avoided are keys whose equality result or object identity constantly changes, yet do not represent the task's lifecycle.

LaunchedEffect(Unit) Does Not Execute Only Once for the Entire Screen Forever

The keyless LaunchedEffect overload exists only as a deprecated error hint and cannot be compiled if called directly. Therefore, we often write:

LaunchedEffect(Unit) {
    load()
}

Unit never changes, so ordinary recompositions will not cause this Effect to restart.

But more accurately, it executes once each time the current call site enters the Composition. If this call site leaves the Composition and later re-enters, LaunchedEffect(Unit) will still execute again (typically executing again when re-entering that screen).

So, Unit means "let the Effect follow the lifecycle of the current call site," not that it executes only once for the entire screen, the entire Activity, or even the entire process forever.

The Magic of rememberUpdatedState

A constant key brings another problem.

Suppose a LaunchedEffect(Unit) contains a long-running operation and also captures an externally passed lambda:

@Composable
fun Timeout(onTimeout: () -> Unit) {
    LaunchedEffect(Unit) {
        delay(3_000)
        onTimeout()
    }
}

If onTimeout changes during the wait, the Effect will not restart because the key is always Unit. What we really want here is not to wait another three seconds, but to call the latest onTimeout when the timer finishes.

In this case, you can use rememberUpdatedState:

@Composable
fun Timeout(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(3_000)
        currentOnTimeout()
    }
}

rememberUpdatedState saves the latest onTimeout but does not cause the LaunchedEffect to restart because of its change.

Therefore, to decide how a value should be passed to an Effect, first ask yourself this question:

These two scenarios look similar, but their lifecycle semantics are completely different.

For Synchronous Tasks, First Look at the Execution Timing, Then at Whether a Coroutine Is Needed

If the block only executes synchronous code, there is indeed no need to subconsciously use LaunchedEffect. But you also shouldn't mechanically replace it with a specific API just because you see a synchronous task.

The key is what lifecycle this code needs:

The RememberedEffect here is the source code shown above; you can use it directly.

Of course, we could also write an empty onDispose {} for DisposableEffect. The code would work fine, but an empty cleanup is usually a hint that the currently chosen API might not perfectly match the semantics of this task.

Do Not Use rememberCoroutineScope to Launch Coroutines Directly Inside Composition

The following code looks like it can run, but it easily starts tasks repeatedly with recompositions:

@Composable
fun UserScreen() {
    val scope = rememberCoroutineScope()
    scope.launch {
        load()
    }
}

rememberCoroutineScope only provides a Scope that follows the call site's lifecycle; it does not turn launch into a safe Composition side effect.

If the task is driven by Composition, use LaunchedEffect; if it is driven by events like clicks or swipes, then use rememberCoroutineScope:

@Composable
fun UserScreen() {
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            scope.launch {
                load()
            }
        }
    ) {
        Text("Load")
    }
}

The "Every Time" of SideEffect Also Has a Scope

We said earlier:

SideEffect executes after every successful commit of the Composition containing it, including the initial composition and subsequent recompositions.

The "every time" here does not mean it will definitely execute whenever any position in the composition tree recomposes.

Only when the code containing SideEffect actually participates in the current Composition will it re-record a pending task. If its enclosing scope is skipped during this recomposition, no new SideEffect invocation will be generated.

Additionally, SideEffect does not run immediately when the Composable function reaches that line. It waits until the current changes are successfully applied before executing, making it suitable for syncing the latest confirmed state from Compose to external objects.

Some Thoughts

In reality, the core of all this knowledge is still remember.

When remember detects a key change, it creates a new RememberObserver and simultaneously makes the old object leave the Composition. The old LaunchedEffectImpl cancels the coroutine in onForgotten, and the new object starts a coroutine in onRemembered. Thus, from the outside, it looks like the Effect was "re-executed."

But what happens at the low level is not a repeated call; it is a complete object replacement and lifecycle switch.

LaunchedEffect, DisposableEffect, and rememberCoroutineScope are all built on top of remember and RememberObserver; they just do different things in their callbacks. SideEffect has no object identity or key and only executes recorded regular code after a successful commit.

As for onAbandoned, we just need to remember: it handles the case where "the object was created, but this Composition was not successfully committed." It and onRemembered → onForgotten are two different lifecycle paths.

Knowing this, choosing an Effect API becomes second nature:

The next time you write LaunchedEffect, you should already be able to see the Scope, the Job, and the object replacement driven by remember hidden behind it.

As for whether this task truly needs a coroutine, it depends on whether it requires suspend capability or just a piece of regular code that follows the Composition lifecycle.