Skip to content

Phase 4 of #66: bind LaunchedEffect / DisposableEffect / SideEffect #102

@jonathanpeppers

Description

@jonathanpeppers

Follow-up to #66 (Phases 1 & 2 are landing in #75; Phase 3 = RememberSaveable follow-up issue).

Problem

With Compose.Remember<T> now slot-table-correct, the next-most-asked-for primitives are the side-effect APIs that schedule work tied to the same slot lifetime:

  • LaunchedEffect(key1, key2, ...) { coroutineBlock } — start a coroutine when the effect enters composition; cancel + relaunch on key change; cancel when the effect leaves composition.
  • DisposableEffect(key1, ...) { onDispose { ... } } — run setup on enter / re-key, run a teardown lambda on leave / re-key.
  • SideEffect { block } — run a synchronous block on every successful composition (no keys, no disposal).

These are needed for any non-trivial app: starting/stopping a Flow, registering a sensor listener, kicking off a snackbar coroutine, etc.

Proposal

Add three static entry points in Compose (or a new Effects.cs):

public static void LaunchedEffect(object? key1, ..., Func<Task> block, ...);
public static void DisposableEffect(object? key1, ..., Func<DisposableEffectScope, IDisposable> block, ...);
public static void SideEffect(Action block, ...);

For LaunchedEffect, route C# async Task → Kotlin suspend () -> Unit through whatever Kotlin-coroutines interop the binding exposes (likely needs an adapter — investigate first; this is the hardest of the three).

For DisposableEffect, IDisposable.Dispose() → the onDispose callback. The DisposableEffectScope parameter exists purely so the C# call shape mirrors the Kotlin DSL (onDispose { ... }); we may collapse it to a plain Func<IDisposable>.

SideEffect is the easiest — just a no-key composer call.

Acceptance

  • LaunchedEffect(channelId, async () => await StartListeningTo(channelId)) cancels and re-launches when channelId changes; cancels on screen exit; surfaces exceptions through the normal C# Task channel.
  • DisposableEffect(Unit) { register sensor; return DisposableAction(() => unregister) } correctly unregisters on screen exit.
  • SideEffect runs after every successful recomposition, never on a skipped one.

Out of scope

  • produceState, derivedStateOf, snapshotFlow — separate follow-ups, less commonly needed.
  • Full Kotlin-coroutines interop ergonomics (e.g. surfacing CoroutineScope cancellation tokens). Start with Task + cancellation on dispose; expand later.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions