android

Jetpack Compose Modifier.Node vs composed(): Making the Right Choice

Jetpack Compose's modifier system lets developers customize UI components without touching their core implementation. You chain changes together, reuse them across your app, and build custom transformations when the built-in options fall short. But when you need something beyond the standard library, choosing the wrong approach can silently tank your app's performance. Two paths exist: the familiar composed() function and the newer Modifier.Node API introduced in Compose 1.3. Here's how to decide.

Understanding the Modifier Foundation

Modifiers in Compose are chainable by design. You layer visual and behavioral changes onto any composable, stacking transformations that compose together. This architecture supports reuse naturally—a custom shimmer effect works anywhere without duplicating animation logic. The real question is implementation: how do you actually build that custom modifier?

The composed() Approach

Composed() wraps modifier logic inside a composable context, giving you access to state observation and other composable functions. You define a function returning a modifier using composed {}, then chain additional modifiers with this.then(). The flexibility is genuine: you can make runtime decisions about behavior based on context.

The cost is performance. Every composition using a composed modifier must call materialize(), adding overhead that compounds with reuse. The Android team has migrated all built-in modifiers away from this approach for exactly this reason.

The Modifier.Node API

Compose 1.3 introduced Modifier.Node as the recommended path forward. The API separates concerns cleanly: a Modifier.Node subclass holds your logic and state, a ModifierNodeElement handles instantiation and updates, and an optional factory provides the public interface.

The state management distinction matters. ModifierNodeElement instances are stateless—fresh allocations happen with each recomposition. Modifier.Node instances, by contrast, persist across recompositions and can be reused. This directly impacts animation smoothness and computational efficiency.

Creating a custom modifier involves implementing these components. For drawing operations, you extend DrawModifierNode. For gesture handling, PointerInputModifierNode handles input interception. The element's create() method instantiates your node with passed parameters, while update() synchronizes property changes when the element detects differences via equals().

Aspect composed() Modifier.Node
State Persistence Resolved per layout at usage site Persists across recompositions
Extractability Tied to composition context Reusable across contexts
CompositionLocal Resolution At usage site At read site
Performance Calls materialize() each time Optimized, avoids materialize()
Lifecycle Creates new instance often Can reuse node instances

When to Use Each Approach

For inline modifiers that apply to a single component with straightforward needs, the @Composable modifier factory offers a simpler alternative with composable context access but less overhead than composed().

For reusable modifiers appearing across multiple components, Modifier.Node is the clear choice. Performance benefits multiply with reuse, and explicit state management prevents subtle bugs. Reserve composed() for cases genuinely requiring its runtime adaptation—typically adapters or inline transformations that cannot be extracted cleanly.

Why This Matters Now

The shift to Modifier.Node follows a pattern: the Compose team identifies bottlenecks, creates explicit APIs addressing them, then migrates internal implementations. Adopting Modifier.Node now positions your code well for future Compose versions. Expect deprecation notices for composed() as the migration continues.

Performance differences become noticeable in real UIs. Consider a shimmer loading effect on a card component. Using Modifier.Node, animation state persists smoothly across recompositions. With composed(), the animation re-initializes constantly, creating jarring visual interruptions and taxing the GPU during each frame—particularly painful on lower-end devices.

Key takeaways

  • Modifier.Node is the recommended approach for custom modifiers due to superior performance and extractability.
  • Reserve @Composable modifier factories for simple, single-component cases; use Modifier.Node by default for anything reused.
  • Modifier.Node instances persist state across recompositions; ModifierNodeElement instances do not.
  • The performance gap widens significantly with reusability and state complexity.
  • Expect future Compose versions to deprecate composed() as the migration pattern continues.

Frequently asked questions

Does Modifier.Node work with all versions of Jetpack Compose?

The Modifier.Node API launched with Compose 1.3. Projects using earlier versions need to upgrade before adopting this approach.

Can I mix Modifier.Node and composed() in the same codebase?

Yes, both approaches can coexist. Prioritize Modifier.Node for new custom modifiers and migrate existing composed() implementations when time permits.

What happens if I keep using composed() instead of Modifier.Node?

Your modifiers will work, but you may experience performance issues in complex UIs with many custom modifier instances. The Android team may deprecate composed() in future releases.

How do I migrate an existing composed() modifier to Modifier.Node?

Identify the state your modifier manages, extract it into a Modifier.Node subclass, and create a corresponding ModifierNodeElement to handle instantiation and updates.

Are there any cases where composed() outperforms Modifier.Node?

For inline, single-use modifiers where state persistence is not needed, the difference is negligible. The performance gap widens with reusability and state complexity.

Related