Skip to content

Cog for Kotlin: effects and background work

Authored August 6, 2026.

The shared state model keeps effects outside automatic state. This document owns the Android lifetimes, APIs, and background-work choices that implement that boundary.

6. Side effects, worked

State describes the app. An effect changes something outside the graph.

First ask if the UI result can be state. State can be restored and tested. Use an effect for a real outside action or a short UI action that cannot be modeled as lasting state.

Examples:

  • send analytics;
  • write a preference;
  • navigate or show a one-time message;
  • start a sync;
  • call hardware;
  • schedule durable work.

Put each effect at the smallest owner that matches its lifetime. The Cog store is app-wide. Effect groups may still be app-wide or screen-scoped.

Rendering diagram…

6.1 Choosing a home for an effect

NeedHome
tied to one Compose callLaunchedEffect or DisposableEffect
tied to a screen modelCogEffects owned by its ViewModel
tied to visible lifecyclelifecycle-aware collection or repeat block
app-session workexplicit application owner and scope
guaranteed later workWorkManager plus durable input
exact user alarmAlarmManager, only when its rules fit
active user-visible long workforeground service, when Android allows it

A ViewModel is not durable. Its coroutine can die with the process.

6.2 A complete effect group

One group owns registrations and jobs:

kotlin
class WeatherViewModel(
    appCogs: AppCogState,
    repository: WeatherRepository,
    analytics: Analytics,
) : ViewModel() {
    private val cogs = appCogs.store
    private val effects = cogs.effects("weather")

    init {
        addCloseable(effects)

        effects.watch(
            name = "analytics: selected zip",
            read = { get(currentZip) },
        ) { zip ->
            if (zip != null) analytics.selectedZip(zip)
        }

        effects.watchLatest(
            name = "load selected weather",
            read = { get(currentZip) },
        ) { zip ->
            if (zip == null) return@watchLatest
            val report = repository.weather(zip)
            cogs.acceptWeather(zip, report)
        }
    }
}

AppCogState is the process singleton from the application root. The ViewModel borrows its store and closes only effects.

watch runs a plain ordered effect. watchLatest gives each run a child job and cancels the old one when its tracked input changes. watchExhaustLatest finishes the active run, then starts only the newest waiting input. It fits preference saves that must not overlap.

Use an async cog instead when loading status or data is itself UI state. Use a reaction when the result is only an outside action.

6.3 Registration and lifecycle

CogEffects is AutoCloseable. Closing it:

  • removes all reaction observations;
  • releases their graph leases;
  • cancels child jobs;
  • blocks late callbacks from writing through the group.

A ViewModel registers its effect group with addCloseable. Closing the ViewModel closes that group, not the process-wide store.

An app-wide owner keeps its own effect groups for the process lifetime. Closing the store closes every remaining child group.

The store creates its own supervisor job under the application scope. Closing an isolated test store cancels that child job. It never cancels the scope that was passed in.

Registration order is effect order within one completed turn. A slow suspending effect does not block later registrations; its launch order is still fixed.

What transfers from Swift's scope retirement, and what does not. Swift uses scope to select a Bool, an optional identity, or a collection of identities. Retirement revokes a controller's graph access as well as cancelling its jobs. Two things in that work are runtime invariants rather than Swift spelling, so they apply here:

  • A closed owner cannot publish through its capability. Kotlin already says closing a group "blocks late callbacks from writing through the group", which is the same claim. Swift found that cancelling jobs and using weak references is not enough to keep it: work can hold a strong reference across a suspension point, so the check has to live in the capability's own operations.
  • A write deferred past its owner's closure must be re-checked when it runs, not when it was requested. Swift's deferred turns carry the exact owner instance and are rejected at their execution point, before any turn or revision exists. Kotlin's effect writes go through the same store-lane turn FIFO, so the same window exists here.

Two things do not transfer. Kotlin's effect groups are owned by Android lifecycle owners, so a group's lifetime is already expressed by AutoCloseable and addCloseable rather than by graph state; Swift needed scope precisely because it has no such owner. And identity-driven replacement is an open receiving-platform question, not a settled requirement: a ViewModel keyed by a navigation entry already gets one group per entry, which is the shape Swift's scope(each:) had to construct. What Kotlin lacks is the session-replacement case — an app-scoped group whose work belongs to one sign-in — and whether that deserves identity-owned groups or an explicit close-and-recreate at the application owner is undecided. It is recorded as open in §10 rather than answered by copying a Swift name.

Errors go to a group error handler with the effect name and turn. The default prototype handler should report and cancel that run, not crash unrelated effects. The final policy remains open.

6.4 Writing back into the graph

An effect writes only through a normal operation:

kotlin
fun CogStore.markDraftSaved(revision: Revision) =
    turn("draft saved") {
        savedRevisionSource.value = revision
    }

effects.watchExhaustLatest(
    name = "save draft",
    read = { get(draft) },
) { value ->
    repository.saveDraft(value)
    cogs.markDraftSaved(value.revision)
}

The write-back is a later turn. It cannot become part of the turn that started the effect.

Protect feedback loops:

  • use equality to stop unchanged values;
  • keep one owner for each writable value;
  • include a revision or request id when two systems echo state;
  • make retries explicit;
  • fail with a readable turn chain when a loop exceeds a debug limit.

Rendering diagram…

6.5 View-scoped effects

Use Compose effect tools for work that exists only because a composable exists:

kotlin
@Composable
fun MapCameraEffect(target: CameraTarget, camera: Camera) {
    LaunchedEffect(target, camera) {
        camera.animateTo(target)
    }
}

Rules:

  • keys must name when the effect should restart;
  • use rememberUpdatedState for a callback that should update without a restart;
  • clean up listeners in DisposableEffect.onDispose;
  • do not launch business work directly from the composition body;
  • send business events to the ViewModel.

For a Flow that should collect only while the UI is visible, use collectAsStateWithLifecycle or repeatOnLifecycle. Direct Cog reads do not need that adapter; their lease already follows composition, and their store follows the app process.

6.6 Testing effects

Tests use a test dispatcher and a store bound to the test lane.

A good effect test should:

  1. install the group;
  2. make one named turn;
  3. advance the test scheduler;
  4. assert the outside call;
  5. assert any later write-back turn;
  6. close the group;
  7. prove later changes do nothing.

Also test cancellation, an error, and a dependency that switches at runtime. Do not use real delays.

6.7 Work that outlives the screen or process

Use WorkManager when work must run after the screen and may need to resume after process death. Give it small durable inputs, not a Cog descriptor or in-memory lambda.

Rendering diagram…

The durable table is the truth. WorkManager is the runner. Cog is the live view. This lets a new process rebuild the same state.

Use unique work and a domain id so scheduling the same job twice has the same result as scheduling it once. A worker should be safe to retry. Report progress through WorkManager or durable storage, then adapt that state into Cog.

Do not use WorkManager for:

  • work that must finish right now;
  • an exact alarm;
  • an endless socket;
  • a task that only matters while one composable is present.

Appendix A: Android background choices

  • WorkManager is the normal choice for deferrable, persistent work.
  • AlarmManager is for time-sensitive alarms and has permission and power limits.
  • A foreground service is user-visible and restricted; it is not a general escape hatch.
  • Room is a strong handoff point when work state must survive restarts.
  • DataStore fits small durable settings, not relational queues.

Start with Android's background task guide, data transfer choices, and alarm guidance.

Appendix B: sources

Released under the MIT License.