Use this skill to author new custom Jetpack Compose modifiers and migrate legacy ones from Modifier.composed { } to Modifier.Node + ModifierNodeElement<T>. Covers the persistent-node lifecycle (onAttach, onDetach, onReset, coroutineScope), the specialized node interfaces (DrawModifierNode, LayoutModifierNode, SemanticsModifierNode, PointerInputModifierNode, CompositionLocalConsumerModifierNode, LayoutAwareModifierNode, GlobalPositionAwareModifierNode, ObserverModifierNode, DelegatingNode, TraversableNode), why ModifierNodeElement MUST be a data class for diffing, and the manual-invalidation knobs (invalidateDraw, invalidateMeasurement, invalidatePlacement, shouldAutoInvalidate). Use when the developer mentions Modifier.composed, custom modifier, ModifierNodeElement, Modifier.Node, "rewriting our drawBehind helper", node lifecycle, or sees Modifier.composed { } in a code review.
Use this skill to author new custom Jetpack Compose modifiers and migrate legacy ones from Modifier.composed { } to Modifier.Node + ModifierNodeElement<T>. Covers the persistent-node lifecycle (onAttach, onDetach, onReset, coroutineScope), the specialized node interfaces (DrawModifierNode, LayoutModifierNode, SemanticsModifierNode, PointerInputModifierNode, CompositionLocalConsumerModifierNode, LayoutAwareModifierNode, GlobalPositionAwareModifierNode, ObserverModifierNode, DelegatingNode, TraversableNode), why ModifierNodeElement MUST be a data class for diffing, and the manual-invalidation knobs (invalidateDraw, invalidateMeasurement, invalidatePlacement, shouldAutoInvalidate). Use when the developer mentions Modifier.composed, custom modifier, ModifierNodeElement, Modifier.Node, "rewriting our drawBehind helper", node lifecycle, or sees Modifier.composed { } in a code review.
license
Apache-2.0. See LICENSE for complete terms.
metadata
{"author":"Jaewoong Eum (skydoves)","keywords":["jetpack-compose","performance","modifier-node","modifier-composed","custom-modifier","draw-modifier-node","layout-modifier-node","composition-local-consumer"]}
Migrating to Modifier.Node — Persistent Nodes Over composed { }
Modifier.composed { } allocates a fresh composable scope per modifier per composition: it cannot
be skipped, cannot be hoisted, and forces the parent to run on every recomposition. Modifier.Node
is a persistent node diffed by ModifierNodeElement.equals() — created once on first apply, updated
in place on subsequent applies. There is no per-recomposition allocation, no fresh composable
scope, and no parent invalidation chain, which is why Android Developers describes the system as "
designed from the ground up to be far more performant" than the legacy composed { } factory (see
developer.android.com/develop/ui/compose/custom-modifiers). This skill teaches Claude how to
author new modifiers as Modifier.Node and migrate legacy composed { } factories.
When to use this skill
A custom modifier currently uses Modifier.composed { } (search the module for
Modifier.composed).
Authoring a new custom modifier from scratch — never start with composed { }.
A code review surfaces a composed { } factory.
The custom modifier needs a CoroutineScope (animation loop, debouncer), reads a
CompositionLocal, participates in layout / drawing / pointer input, or tracks layout
coordinates.
A @TraceRecomposition log shows the parent composable recomposing on every frame because a
composed { } modifier is in the chain.
When NOT to use this skill
The "modifier" is actually a one-line composable wrapper that can stay a @Composable function —
leave it alone.
The built-in modifier composition (Modifier.padding(...).clickable(...)) is sufficient, no
custom node behavior needed.
The fix the developer needs is reordering an existing chain, not authoring a new node — see
../ordering-modifier-chains/SKILL.md.
The custom modifier reads a hot animation value via Modifier.composed { } only to feed a
value-form modifier underneath — the underlying issue is a wrong-phase state read; see
../../recomposition/deferring-state-reads/SKILL.md.
Prerequisites
Compose UI 1.5+ — Modifier.Node and the specialized node interfaces are stable here.
Kotlin 2.0+ with org.jetbrains.kotlin.plugin.compose applied. Strong Skipping is on by
default; non-skippable modifiers compound at scroll velocity.
A release build for any final measurement (skydoves hot take #5: debug builds run interpreted and
lie).
For the full per-interface override surface and lifecycle diagram, read
references/modifier-node-anatomy.md before authoring anything beyond a DrawModifierNode.
Workflow
1. Identify every Modifier.composed { } factory in the module. Grep for
Modifier.composed. Each match is one migration target. Note what the body does — remember,
drawBehind, LaunchedEffect, a CompositionLocal read, a pointer handler — because that
decides which specialized node interface(s) you need.
2. Sketch the three pieces every Modifier.Node migration produces.
(a) The public extension — fun Modifier.foo(...): Modifier = this then FooElement(...).
Same name and signature as the old composed { } factory.
(b) The ModifierNodeElement<T>data class — holds the parameters; implements
create() (called once on first apply) and update(node: T) (called on subsequent applies).
(c) The Modifier.Node subclass — holds mutable state (var fields), implements one or
more specialized node interfaces, and runs lifecycle hooks (onAttach, onDetach,
onReset).
3. Make the Element a data class. The compiler-synthesized equals()/hashCode() are
how Compose decides whether to call update() vs leave the node alone. MUST be data class.
A plain class falls back to referential equality, the diff thinks every apply is a new modifier,
and update() is never called — your node holds stale parameters silently.
4. Pick the right specialized node interface(s). A Modifier.Node is empty by itself;
behavior comes from interfaces it implements. The common ones:
Interface
Use when the modifier needs to …
DrawModifierNode
draw (replaces drawBehind/drawWithCache)
LayoutModifierNode
measure/place (replaces layout { } and custom Layout)
get notified when this node's size/coordinates change
GlobalPositionAwareModifierNode
get notified about position in the window/root
ObserverModifierNode
observe arbitrary state reads with a custom observer (observeReads { ... })
DelegatingNode
compose multiple node behaviors by delegating to child nodes
TraversableNode
walk the modifier chain (parent/child traversal)
A node MAY implement several interfaces at once — e.g.
DrawModifierNode + CompositionLocalConsumerModifierNode + LayoutAwareModifierNode. For complex
multi-behavior modifiers, PREFERRED: compose smaller DelegatingNode children rather than one
mega-node implementing five interfaces.
5. Use the built-in coroutineScope for async work. Every Modifier.Node exposes a
coroutineScope: CoroutineScope lazily tied to the node's attach/detach lifecycle. Launch
animations, observers, debouncers there from onAttach(). MUST NOT create your own
CoroutineScope inside onAttach — you will leak it past onDetach.
6. Trigger re-runs explicitly when needed. When you mutate node state from inside
update() or a coroutine and need a redraw / re-measure / re-place, call invalidateDraw(),
invalidateMeasurement(), or invalidatePlacement(). By default, update() triggers an
auto-invalidation; for fine control, override shouldAutoInvalidate = false and invalidate
manually.
7. Implement onAttach/onDetach/onReset for resource lifecycle.onAttach runs when
the node joins the tree; onDetach when it leaves; onReset when the node is reused (only
relevant inside lazy layouts). MUST release listeners, observers, and external subscriptions
in onDetach.
8. Verify migration: no Modifier.composed { } remains. Re-grep the module. If any
composed { } calls survive, list them with rationale; otherwise the migration is complete.
Patterns
Pattern: migrate composed { drawBehind } to DrawModifierNode
// WRONG (legacy)fun Modifier.circle(color: Color): Modifier = composed {
val computed = remember(color) { color.copy(alpha = 0.5f) }
drawBehind { drawCircle(computed) }
}
// WRONG because: composed { } opens a fresh composable scope per parent recomposition; the modifier can never be skipped and forces the parent to recompose on every read it does inside.
The data class Element gives equals() / hashCode() for free. When the caller passes the same
color, equals() returns true and the node is left alone. When the color changes, update()
mutates node.color in place — no allocation, no Composition invalidation in the parent.
Pattern: coroutine work in a node (replace composed { LaunchedEffect })
// WRONG (legacy)fun Modifier.pulse(period: Long): Modifier = composed {
val alpha = remember { Animatable(1f) }
LaunchedEffect(period) {
while (true) { alpha.animateTo(0.3f); alpha.animateTo(1f); delay(period) }
}
graphicsLayer { this.alpha = alpha.value }
}
// WRONG because: every parent recomposition allocates a new composable scope, and the LaunchedEffect's keying logic re-evaluates inside that scope.
CompositionLocalConsumerModifierNode exposes currentValueOf(local) from inside any node
callback. Reads are tracked by the Draw invalidation list, not by a Composition restart scope, so
changing LocalThemeTokens redraws the node without recomposing the parent.
Pattern: forgetting data class (the silent-stale-node bug)
// WRONGprivateclassCircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
overridefuncreate() = CircleNode(color)
overridefunupdate(node: CircleNode) { node.color = color }
}
// WRONG because: not a data class -> equals() is referential -> every apply looks like a different element -> Compose tears down and recreates the node every time, OR the diff fails and update() is never called, leaving the node with the original color forever.
Pattern: holding a reference to the calling composable
// WRONGprivateclassHostingNode(val composer: Composer) : Modifier.Node() { /* ... */ }
// WRONG because: a Modifier.Node outlives any single composition pass; holding a Composer/composition-scoped object leaks it and invokes undefined behavior.
// RIGHT — accept primitive/stable parameters; read CompositionLocals via CompositionLocalConsumerModifierNode if you need composition context.privatedataclassHostingElement(val tag: String) : ModifierNodeElement<HostingNode>() {
overridefuncreate() = HostingNode(tag)
overridefunupdate(node: HostingNode) { node.tag = tag }
}
privateclassHostingNode(var tag: String) : Modifier.Node() { /* ... */ }
Pattern: composing multiple behaviors with DelegatingNode
// RIGHT — one public modifier, three small nodes delegated under one elementprivatedataclassCardEffectsElement(
val color: Color,
val onClick: () -> Unit,
) : ModifierNodeElement<CardEffectsNode>() {
overridefuncreate() = CardEffectsNode(color, onClick)
overridefunupdate(node: CardEffectsNode) {
node.update(color, onClick)
}
}
privateclassCardEffectsNode(
color: Color,
onClick: () -> Unit,
) : DelegatingNode() {
privateval background = delegate(BackgroundNode(color))
privateval click = delegate(ClickNode(onClick))
funupdate(color: Color, onClick: () -> Unit) {
background.color = color
click.onClick = onClick
}
}
DelegatingNode is the canonical way to assemble multi-behavior modifiers without one node
implementing every interface. The delegated children share the host's lifecycle.
Specialized node interfaces
Cheat sheet — full override surface and "use when" guidance lives in
references/modifier-node-anatomy.md:
DrawModifierNode — implement ContentDrawScope.draw(). Replaces drawBehind/drawWithCache
for custom modifiers.
ObserverModifierNode — wrap state reads with observeReads { ... } and react in
onObservedReadsChanged().
DelegatingNode — delegate(otherNode) to compose behaviors.
TraversableNode — walk parents/children/descendants via the top-level extension functions on
DelegatableNode: traverseAncestors(key, block), traverseChildren(key, block),
traverseDescendants(key, block). The key parameter selects which traversable nodes
participate; the descendants overload's block returns a TraverseDescendantsAction (continue /
skip / cancel).
Lifecycle (short form — full diagram in references)
ModifierNodeElement.create() // first apply only
↓
Modifier.Node.onAttach() // node joins the tree; coroutineScope becomes valid
↓ // (lives here across many parent recompositions)
ModifierNodeElement.update(node) // each subsequent apply with !equals previous
↓ // mutate node.var fields; auto-invalidates by default
Modifier.Node.onReset() // optional: lazy-layout reuse
↓
Modifier.Node.onDetach() // node leaves the tree; coroutineScope is cancelled
Mandatory rules
MUST prefer Modifier.Node for any new custom modifier — Modifier.composed { } is legacy.
MUST make ModifierNodeElement<T> a data class so the synthesized equals()/hashCode()
drive the diff. Plain class silently breaks update().
MUST override update(node: T) to mutate node state in place. MUST NOT recreate the node
from update().
MUST release subscriptions, listeners, and external resources in onDetach().
coroutineScope is cancelled for you; manual resources are not.
MUST NOT hold a reference to the Composer, the calling composable, the parent composition,
or any composition-scoped object inside a Modifier.Node.
MUST NOT allocate a new CoroutineScope in onAttach — use the built-in coroutineScope
property.
MUST NOT recommend Modifier.composed { } for new code. (Repo-wide rule from SPEC §8.)
PREFERRED: specialized node interfaces (DrawModifierNode, LayoutModifierNode, …) over a
bare Modifier.Node.
PREFERRED:DelegatingNode over implementing more than ~3 specialized interfaces on a single
node.
PREFERRED: override shouldAutoInvalidate = false and call invalidateDraw() /
invalidateMeasurement() / invalidatePlacement() explicitly when fine-grained control matters;
otherwise rely on the auto-invalidation triggered by update().
Verification
grep -R "Modifier.composed" <module>/src returns zero matches in the migrated module.
Every migrated modifier exposes a data class Element (search:
class .*Element : ModifierNodeElement should be data class).
The compiler report (composables.txt) shows the parent composables that consume the migrated
modifier are now restartable skippable (no composed-induced non-skip).
Layout Inspector → Recomposition Counts on the parent composable plateaus across animation
frames driven by the modifier; only the node's draw/layout invalidation list ticks.
@TraceRecomposition (skydoves/compose-stability-analyzer) on the parent confirms in *
release + R8 + real device* that the parent is not recomposed by the modifier's internal
animation.
Resources allocated in onAttach (listeners, observers) are released in onDetach — verify
with a leak canary pass.