using-efficient-effects — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited using-efficient-effects (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Compose ships three effect APIs by default: LaunchedEffect (coroutine, restarts on key change), DisposableEffect (setup/teardown with a cleanup block), and SideEffect (runs on every successful composition). The skydoves/compose-effects library adds RememberedEffect — a non-coroutine analog of LaunchedEffect. Picking the wrong API wastes a coroutine scope, leaks a listener, or silently drops the latest version of a callback. This skill is the decision tree.
LaunchedEffect restarts unexpectedly.LaunchedEffect calls a stale version of a callback that the parent has updated.LazyColumn row needs its own ViewModel instance (e.g. one VM per chat row, one VM per feed card) and the parent VM store would leak state across rows.SideEffect.State<T> from another — use derivedStateOf. See ../../recomposition/choosing-derivedstateof/SKILL.md.collectAsStateWithLifecycle. See ../collecting-flows-safely/SKILL.md.remember { ... }, not LaunchedEffect(Unit).RememberedEffect, add com.github.skydoves:compose-effects (latest GA — see https://github.com/skydoves/compose-effects).ViewModelStoreScope, also depend on com.github.skydoves:compose-effects-viewmodel. Import path: com.skydoves.compose.effects.viewmodel.ViewModelStoreScope.ViewModelStoreScope with Hilt, also androidx.hilt:hilt-navigation-compose for hiltViewModel().LaunchedEffect(key1, key2, ...). The block is launched in the composition's CoroutineScope and cancelled/relaunched whenever any key changes. Use for network calls, animations, suspending work.DisposableEffect(key) { onDispose { ... } }. The onDispose block runs on key change AND when the composable leaves composition. Use for listeners, observers, manual subscriptions.RememberedEffect(key) { ... } from skydoves/compose-effects. Cheaper than spinning a LaunchedEffect scope just to call a synchronous function. No onDispose — pair with DisposableEffect if you need teardown.val latest by rememberUpdatedState(callback) and reference latest inside the effect. The effect keeps the same coroutine; the callback reference stays fresh.ViewModelStoreScope(key = <stableId>) { ... } from com.github.skydoves:compose-effects-viewmodel and call hiltViewModel() (or viewModel()) inside. The key is required and must be a stable identifier (the row's id, etc.); each key gets its own store.SideEffect { ... }. Rare. Use for publishing Compose state to a non-Compose system that you cannot subscribe to via DisposableEffect (e.g. updating a View's field on every commit). Avoid for per-frame work — it really does run every commit.LaunchedEffect// WRONG
@Composable
fun Timer(onTick: () -> Unit) {
LaunchedEffect(Unit) {
while (true) {
delay(1_000)
onTick()
}
}
}
// WRONG because: onTick is captured by the first composition's value; later parents pass an
// updated lambda but the running coroutine still holds the original reference -> the new
// onTick is silently ignored.// RIGHT
@Composable
fun Timer(onTick: () -> Unit) {
val latest by rememberUpdatedState(onTick)
LaunchedEffect(Unit) {
while (true) {
delay(1_000)
latest()
}
}
}
// rememberUpdatedState updates the ref on every recomposition; the coroutine reads the freshest
// reference each tick without restarting.DisposableEffect for a non-coroutine subscriber// WRONG
@Composable
fun LocationBadge(client: LocationClient) {
LaunchedEffect(client) {
try {
val listener = LocationListener { /* ... */ }
client.register(listener)
awaitCancellation()
} finally {
client.unregisterAll()
}
}
}
// WRONG because: spawning a coroutine just to hold a listener wastes a scope and obscures the
// teardown contract; awaitCancellation is the wrong primitive for a non-suspending subscriber.// RIGHT
@Composable
fun LocationBadge(client: LocationClient) {
DisposableEffect(client) {
val listener = LocationListener { /* ... */ }
client.register(listener)
onDispose { client.unregister(listener) }
}
}RememberedEffect over LaunchedEffect for synchronous key reactions// WRONG (over-engineered)
LaunchedEffect(themeKey) {
applyTheme(themeKey)
}
// WRONG because: spawns and cancels a coroutine scope just to call a synchronous function on
// key change. No suspension happens; the scope is pure overhead.// RIGHT (with com.github.skydoves:compose-effects)
import com.skydoves.compose.effects.RememberedEffect
RememberedEffect(themeKey) {
applyTheme(themeKey)
}ViewModelStoreScope for per-row ViewModels in a LazyColumn// WRONG
@Composable
fun Feed(rows: List<Row>) {
LazyColumn {
items(rows, key = { it.id }) { row ->
// hiltViewModel() here resolves to the parent screen's store -> every row shares
// the same VM instance, leaking state across rows.
val vm = hiltViewModel<RowVm>()
RowContent(row, vm)
}
}
}
// WRONG because: every row's hiltViewModel call resolves against the parent NavBackStackEntry
// store -> one VM is shared across all rows -> per-row state collapses into one slot.// RIGHT
import com.skydoves.compose.effects.viewmodel.ViewModelStoreScope
@Composable
fun Feed(rows: List<Row>) {
LazyColumn {
items(rows, key = { it.id }) { row ->
ViewModelStoreScope(key = row.id) {
val vm = hiltViewModel<RowVm>()
RowContent(row, vm)
}
}
}
}
// ViewModelStoreScope(key = ...) installs a composable-scoped ViewModelStore tied to the
// supplied key; each row gets its own VM and onCleared fires when the row leaves composition.
// The key MUST be stable across recompositions — the row's id is the canonical choice.remember { } vs LaunchedEffect(Unit) for one-shot work// WRONG
@Composable
fun Greeter(name: String) {
LaunchedEffect(Unit) {
Log.d("Greeter", "First seen: $name")
}
}
// WRONG because: LaunchedEffect(Unit) re-runs after configuration change recreation when the
// composable is re-attached to a new composition. For "do this once and never again across
// the lifetime of this composable instance", use remember.// RIGHT
@Composable
fun Greeter(name: String) {
remember { Log.d("Greeter", "First seen: $name") }
}
// remember { } evaluates its block once per slot and never again unless the slot is removed
// and re-created. No coroutine, no effect machinery.rememberUpdatedState when consumed inside a long-lived LaunchedEffect (one whose key set does not change when the callback identity changes). Otherwise the effect captures the stale lambda forever.DisposableEffect over LaunchedEffect { try { } finally { } } for non-coroutine subscribers. The cleanup contract is explicit and runs on key change as well as composition exit.LaunchedEffect(Unit) to run a one-shot operation that must survive recomposition without re-running. Use remember { ... } for that.SideEffect for per-frame work or for anything that allocates. It runs on every successful commit.ViewModelStoreScope(key = <stableId>) { hiltViewModel<T>() } (from com.github.skydoves:compose-effects-viewmodel) when calling hiltViewModel() inside a LazyColumn / LazyRow item that needs its own VM instance — without it, all rows share the parent screen's store. The key parameter is required and MUST be a stable id (e.g. the row's domain id).RememberedEffect(key) { ... } from skydoves/compose-effects over LaunchedEffect(key) { ... } when the body does not suspend. Cheaper than allocating a coroutine scope on every key change.LaunchedEffect(viewModel) { viewModel.flow.collect { ... } } — use collectAsStateWithLifecycle() instead. See ../collecting-flows-safely/SKILL.md.@TraceRecomposition on the host composable shows the expected number of effect runs per key change (typically: one teardown + one setup per key change; zero on unrelated recompositions). See ../../measurement/tracing-recompositions-at-runtime/SKILL.md.DisposableEffect.onDispose { ... } and confirm it fires on key change AND on backgrounding the host screen.LaunchedEffect sees the latest value via rememberUpdatedState.ViewModelStoreScope rows, log the row VM's init { } and onCleared() — they should fire per row, scoped to that row's identity, not once for the whole list.SideEffect { } block in the codebase performs allocation-heavy work; it should be a thin pointer write to a non-Compose subscriber.LaunchedEffect(Unit) { /* non-suspending work */ } remains — replace with remember { ... } (one-shot) or RememberedEffect(key) { ... } (key-driven).RememberedEffect, ViewModelStoreScope): https://github.com/skydoves/compose-effects../collecting-flows-safely/SKILL.md for collectAsStateWithLifecycle and the Flow-as-parameter antipattern.../../recomposition/choosing-derivedstateof/SKILL.md for the State→State derivation case (not an effect).../../measurement/tracing-recompositions-at-runtime/SKILL.md for @TraceRecomposition setup.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.