Exploring Modifier.Node for creating custom Modifiers in Jetpack Compose

In this article, you will learn how to create custom modifiers using the three primary APIs, Modifier.then(), Modifier.composed(), and Modifier.Node

Jaewoong Eum
Published

In modern Android development, Jetpack Compose has become a modern UI toolkit, not only for building Android UIs but also for supporting Kotlin Multiplatform projects. Its ecosystem continues to grow rapidly, making it an essential part of today’s Android development.

At the core of Compose lies the Modifier. A modifier is more than just a way to apply styles and themes, it’s a design pattern that enables components to remain independent while still being easily customizable. By creating custom modifiers, you can encapsulate behavior once and reuse it throughout your app, making your UI code both cleaner and more flexible.

In this article, we’ll explore how to create custom modifiers with primary three different APIs, Modifier.then(), Modifier.composed(), and Modifier.Node, and demonstrates how RevenueCat’s Android SDK leverages Modifier.Node to deliver better UI performance.

Modifier.then

This is the easiest way to create a custom modifier by using the Modifier.then() function, letting you combine multiple modifiers through chaining and return the final result, as shown in the example below:

1@Composable
2fun Modifier.composableModifier(): Modifier {
3    val color = LocalContentColor.current.copy(alpha = 0.5f)
4    return this then Modifier.background(color)
5}
6
7@Composable
8fun MyComposable() {
9    val composedModifier = Modifier.composableModifier() // Cannot be extracted any higher
10}

The infix fun Modifier.then(other: Modifier) is the fundamental operator responsible for chaining modifiers in Jetpack Compose. It does not create a flat list but rather a recursive, two-element data structure called CombinedModifier. This structure functions as a form of cons list or linked list, optimized for the specific directional traversal patterns required by the Compose UI toolkit.

Let’s say there’s a simple modifier, that allows developers to chain modifiers declaratively to build up the properties of a UI element:

1Modifier
2    .padding(16.dp)      // First (outer)
3    .background(Color.Blue) // Second
4    .clickable { }       // Third (inner)

One might assume this creates a List<Modifier.Element>. However, this is not the case. The Modifier.then() function, which is implicitly called by each subsequent modifier in the chain, constructs a nested, tree-like structure. This design is a deliberate and crucial architectural choice for performance and correctness. 

If you look into the internal codes of the Modifier.then() function, you’ll find it internally creates a new CombinedModifier instance.

1
2infix fun then(other: Modifier): Modifier =
3    if (other === Modifier) this else CombinedModifier(this, other)
4        
5 class CombinedModifier(internal val outer: Modifier, internal val inner: Modifier) : Modifier {
6    override fun <R> foldIn(initial: R, operation: (R, Modifier.Element) -> R): R =
7        inner.foldIn(outer.foldIn(initial, operation), operation)
8
9    override fun <R> foldOut(initial: R, operation: (Modifier.Element, R) -> R): R =
10        outer.foldOut(inner.foldOut(initial, operation), operation)
11
12    override fun any(predicate: (Modifier.Element) -> Boolean): Boolean =
13        outer.any(predicate) || inner.any(predicate)
14
15    override fun all(predicate: (Modifier.Element) -> Boolean): Boolean =
16        outer.all(predicate) && inner.all(predicate)
17
18    override fun equals(other: Any?): Boolean =
19        other is CombinedModifier && outer == other.outer && inner == other.inner
20
21    override fun hashCode(): Int = outer.hashCode() + 31 * inner.hashCode()
22
23    override fun toString() =
24        "[" +
25            foldIn("") { acc, element ->
26                if (acc.isEmpty()) element.toString() else "$acc, $element"
27            } +
28            "]"
29}

Its sole purpose is to take two modifiers, the existing chain (this) and a new one (other), and join them using the CombinedModifier class. The CombinedModifier is not a collection; it is a simple, two-element container. It acts as a node in a linked list.

1class CombinedModifier(internal val outer: Modifier, internal val inner: Modifier) : Modifier
  • outer: This holds the modifier that came *before* in the chain.
  • inner: This holds the modifier that was just added.

When you write Modifier.padding(8.dp).background(Color.Red), the resulting structure is: CombinedModifier(outer = CombinedModifier(outer = padding, inner = background), inner = clickable)

This creates a left-associative, recursive structure that looks like this: ((padding then background) then clickable)

This “linked list” structure is the key to understanding how modifiers are processed. The creative CombinedModifier structure is realized through its traversal methods. A standard list has one way to iterate (from start to end). CombinedModifier has two, with opposite directions, which are important for the different phases of the UI pipeline.

So, ultimately, Modifier.then is the simple yet optimized function that enables the entire declarative and chainable modifier system, forming the backbone of UI customization in Jetpack Compose.

Modifier.composed

Another available API is Modifier.composed(), which lets you create a custom modifier that can leverage composable functions inside the lambda parameter.

1fun Modifier.myStatefulModifier() = composed {
2    val myState by remember { mutableStateOf(0f) }
3    // ... use myState
4    Modifier.drawBehind { /* ... */ }
5}

Internally, it behaves much like Modifier.then, since it creates a new instance of ComposedModifier and combines it with the existing modifier using Modifier.then, as shown below:

1fun Modifier.composed(
2    inspectorInfo: InspectorInfo.() -> Unit = NoInspectorInfo,
3    factory: @Composable Modifier.() -> Modifier,
4): Modifier = this.then(ComposedModifier(inspectorInfo, factory))

While Modifier.composed makes it easy to create custom modifiers, this API is no longer recommended because of the performance issues it introduces. The method allows you to invoke @Composable functions, such as remember, when creating a modifier instance. This is useful for attaching an instance-specific state, which lets the same modifier object be reused safely in multiple places.

However, if you use Modifier.composed without actually calling any @Composable functions, it becomes unnecessary overhead. In this case, the modifier is no longer considered “skippable”, which forces the runtime to perform additional work during recomposition. As a result, every recomposition re-executes the modifier chain to update state, leading to degraded performance.

Modifier.Node: The state and logic

Modifier.Node has been introduced since Compose UI version 1.3.0, and now this is the most recommended API for creating a custom Modifier against Modifier.composed. While the Modifier.composed factory provided an early mechanism for creating custom, stateful modifiers, it came with a significant performance cost: the creation of a new subcomposition for every modifier instance. This overhead could lead to degraded performance, especially in dynamic UIs like lists.

The Modifier.Node system was introduced to solve this problem directly. It is a new, lower-level API that allows developers to create stateful modifiers that are lightweight, lifecycle-aware, and integrated into Compose’s core rendering pipeline, without the overhead of subcomposition.

Modifier.Node

The Modifier.Node implementation is the heart of your custom modifier. It is a stateful, mutable class that holds the modifier’s properties and implements its core logic. Critically, a Modifier.Node instance can survive across multiple recompositions and can even be reused if its configuration remains the same.

1// The Node: Holds state (color) and logic (drawCircle)
2private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
3    override fun ContentDrawScope.draw() {
4        // Implements the drawing logic
5        drawCircle(color)
6    }
7}

The Element has two primary responsibilities:

1.  create(): Instantiates the Modifier.Node the very first time the modifier is applied.

2.  update(node: CircleNode): This is the key to performance. On subsequent recompositions, if the Element has changed (determined by its equals method), the update function is called on the existing Node instance. This allows you to update the Node's state without the expensive cost of creating a new object.

Note: It is critical that your ModifierNodeElement correctly implements equals and hashCode. Using a data class is the easiest way to ensure this. Without a correct implementation, update may be called unnecessarily, defeating the performance benefits.

The Modifier Factory

This is the clean, public API surface function that developers will use. It simply uses the then operator to chain the new ModifierNodeElement onto the existing modifier chain.

1// The Factory: The clean public API
2fun Modifier.circle(color: Color): Modifier = this.then(CircleElement(color))

You can now use your custom modifier just like any other built-in modifier function. By separating the stateless factory (Element) from the stateful worker (Node), it minimizes allocations and maximizes object reuse. Its rich set of specialized node types and lifecycle-aware APIs provides developers with direct, safe, and efficient access to every phase of the UI pipeline.

RevenueCat’s Android SDK also leverages Modifier.Node to achieve better UI performance. We recently migrated from the older Modifier.composed() API to Modifier.Node when implementing the placeholder feature. You can check out Placeholder.kt file to see the full changes and how this works in a real-world example.

Conclusion

 In this article, you’ve explored how to create custom modifiers using the three primary APIs, Modifier.then(), Modifier.composed(), and Modifier.Node, and learned how the RevenueCat SDK leverages Modifier.Node to improve UI performance. Understanding the trade-offs between these approaches is key: while Modifier.then() is simple and lightweight, Modifier.composed() introduces performance costs when misused, and Modifier.Node offers a more modern, efficient way to build custom behaviors. By applying these patterns thoughtfully, you can create modifiers that are not only reusable and expressive but also optimized for performance, making your Compose UIs more stable.

You might also like

Share this post