How Decompose Handles Nested Component Scopes in Kotlin Multiplatform

Decompose handles nested component scopes by creating isolated child contexts through the childContext extension function, which merges parent-child lifecycles while keeping state, retained instances, and back-handlers separate for each level of the component hierarchy.

The Decompose library by Arkadii Ivanov provides a robust architecture for building component-based applications in Kotlin Multiplatform. When building complex UIs with nested navigation or deeply nested feature modules, understanding how Decompose manages nested component scopes is essential for maintaining proper lifecycle boundaries and state isolation.

Understanding Component Contexts in Decompose

At the core of Decompose's architecture is the ComponentContext interface, defined in ComponentContext.kt. This context provides the essential services every component needs: lifecycle management, state preservation, instance retention, and back-button handling.

The GenericComponentContext.kt file defines the generic base interface that includes:

  • lifecycle: Lifecycle – The component's lifecycle state
  • stateKeeper: StateKeeper – Handles saved state across process death
  • instanceKeeper: InstanceKeeper – Retains objects across configuration changes
  • backHandler: BackHandler – Manages back button interception

When a parent component needs to create a child, it cannot simply pass its own context. Doing so would cause lifecycle leaks and state collisions. Instead, Decompose provides a mechanism to create nested scopes that inherit from yet remain isolated from the parent.

Creating Nested Component Scopes with childContext

The primary API for building nested component scopes is the childContext extension function, implemented in ComponentContextExt.kt.

The childContext Function Signature

fun <Ctx : GenericComponentContext<Ctx>> Ctx.childContext(
    key: String,
    lifecycle: Lifecycle? = null,
    backHandlerPriority: Int = BackCallback.PRIORITY_DEFAULT,
): Ctx

This function is an extension on GenericComponentContext, meaning any component context can create a child context. The function returns the same concrete type (Ctx) as the receiver, enabling type-safe chaining for deep hierarchies.

Lifecycle Merging and Isolation

When creating a nested scope, Decompose must ensure the child respects both its own lifecycle and the parent's. If the parent is destroyed, the child must also be destroyed, regardless of its own state.

The implementation handles this through MergedLifecycle:

lifecycle = if (lifecycle == null) this.lifecycle else MergedLifecycle(this.lifecycle, lifecycle)

If no custom lifecycle is provided, the child inherits the parent's lifecycle directly. If a custom lifecycle is supplied (useful for navigation-driven components), Decompose creates a MergedLifecycle that combines both. This ensures the child receives ON_CREATE, ON_START, ON_RESUME only when both parent and child lifecycles allow it, and receives ON_PAUSE, ON_STOP, ON_DESTROY when either parent or child transitions down.

State and Instance Isolation

To prevent state collisions between siblings or parent-child pairs, Decompose isolates the StateKeeper and InstanceKeeper for each nested scope.

In ComponentContextExt.kt, the implementation calls:

stateKeeper = stateKeeper.child(key, lifecycle)
instanceKeeper = instanceKeeper.child(key, lifecycle)

The child method creates a namespaced sub-registry using the provided key. This means:

  • Saved state for the child is stored under a unique key, preventing overwrite by the parent or siblings
  • When the child's lifecycle is destroyed, the parent automatically cleans up the child's saved state
  • Retained instances are similarly scoped, ensuring objects like ViewModels or presenters are kept only as long as the specific nested component exists

Back-Handler Scoping

Back button handling in nested scopes requires careful ordering. When the user presses back, the most specific (deepest) active component should typically handle the event first.

Decompose achieves this through:

backHandler = backHandler.child(lifecycle, backHandlerPriority)

The child method creates a nested back-handler that registers with the parent using the specified priority. The backHandlerPriority parameter (defaulting to BackCallback.PRIORITY_DEFAULT) controls the order in which callbacks are invoked. Higher priority callbacks are invoked before lower priority ones, allowing child components to intercept back presses before parents handle them.

Building Deep Component Hierarchies

Because childContext returns the same type it receives, you can chain calls to build arbitrarily deep trees:

val level1 = rootContext.childContext("level1")
val level2 = level1.childContext("level2")
val level3 = level2.childContext("level3")

Each level maintains its own isolated state and instances while respecting the merged lifecycle chain. If rootContext is destroyed, all levels automatically receive destruction events. If only level2 is destroyed (perhaps via navigation), level1 remains active while level3 is cleaned up.

Navigation utilities in Decompose leverage childContext internally to ensure proper scoping. The childStack, childPages, and childPanels functions (found in the navigation modules) automatically invoke the component context factory to obtain fresh contexts for each navigated child.

As implemented in ComponentContextFactoryOwner.kt, the factory exposes:

val componentContextFactory: ComponentContextFactory<Ctx>

This factory is used by navigation models to create contexts that are automatically scoped to the navigation entry's lifecycle, ensuring that when a screen is popped from the stack, its state and instances are properly disposed.

Practical Implementation Example

Here is a complete example demonstrating nested component scopes in a real-world scenario:

class RootComponent(
    componentContext: ComponentContext,
) : Component {

    // First-level child scope with isolated state
    private val childCtx = componentContext.childContext(key = "child")

    // Nested child component that itself creates another child scope
    private val nested = ChildComponent(childCtx)

    // ...
}

class ChildComponent(
    componentContext: ComponentContext,
) : Component {

    // Second-level (nested) scope with its own state keeper and instance keeper
    private val innerCtx = componentContext.childContext(key = "inner")

    private val inner = InnerComponent(innerCtx)

    // ...
}

class InnerComponent(
    componentContext: ComponentContext,
) : Component {
    // Deepest level - still has isolated state and lifecycle
    // ...
}

In this hierarchy:

  • RootComponent creates a child scope named "child" with its own StateKeeper and InstanceKeeper
  • ChildComponent further creates its own child scope "inner", isolated from both the root and its siblings
  • Each scope respects the merged lifecycle chain, ensuring proper cleanup when any ancestor is destroyed
  • Back button handling is scoped, allowing InnerComponent to intercept back presses before ChildComponent or RootComponent handle them

Summary

Decompose handles nested component scopes through a sophisticated context inheritance system that balances isolation with proper lifecycle propagation:

  • The childContext extension function in ComponentContextExt.kt creates nested scopes while preserving the concrete context type for type-safe chaining
  • MergedLifecycle combines parent and child lifecycles, ensuring children respect ancestor state changes while maintaining their own lifecycle independence
  • Isolated StateKeeper and InstanceKeeper instances prevent state collisions between siblings and parent-child pairs, with automatic cleanup on destruction
  • BackHandler scoping with priority support allows deep components to intercept back presses before ancestors handle them
  • Navigation utilities like childStack and childPages leverage ComponentContextFactoryOwner to automatically scope navigated children

This architecture enables arbitrarily deep component trees while maintaining clear boundaries for state, lifecycle, and navigation concerns.

Frequently Asked Questions

How does Decompose prevent memory leaks when creating nested component scopes?

Decompose prevents memory leaks through the MergedLifecycle mechanism implemented in ComponentContextExt.kt. When a parent component is destroyed, its lifecycle transitions to DESTROYED, which automatically triggers the merged lifecycle to propagate destruction events to all child scopes. Additionally, the StateKeeper and InstanceKeeper child instances are tied to the child's lifecycle; when the child is destroyed, these registries automatically clean up their associated resources, preventing retained references from leaking memory.

Can I customize the lifecycle of a nested component independently from its parent?

Yes, Decompose supports custom lifecycles for nested components through the optional lifecycle parameter in the childContext function. When you provide a custom Lifecycle, Decompose creates a MergedLifecycle that combines both the parent and custom lifecycles. This allows the child to respond to its own manual lifecycle events (such as those driven by navigation visibility changes) while still respecting the parent's lifecycle boundaries. The child will only be in the RESUMED state when both the parent and custom lifecycle allow it.

What is the difference between fixed child components and navigation-driven child components in Decompose?

Fixed child components are created directly via childContext and typically live for the entire duration of the parent component, with their lifecycle directly tied to the parent's lifecycle (or a subset via custom lifecycle). Navigation-driven child components are created indirectly through navigation utilities like childStack, childPages, or childPanels, which internally use ComponentContextFactoryOwner to create scoped contexts automatically. These navigation-driven children have lifecycles managed by the navigation state (e.g., being destroyed when popped from a stack), while fixed children remain until the parent is destroyed or manually cleaned up.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →