Skip to content

Component Overview

A component is just a normal class that encapsulates some logic and possibly another (child) components. Every component has its own lifecycle, which is automatically managed by Decompose. So everything encapsulated by a component is scoped. Please head to the Lifecycle documentation page for more information.

UI is optional and is pluggable from outside of components. Components do not depend on UI, the UI depends on components.

Unlike the traditional approach with ViewModels and navigation from UI (when a ViewModel is passed to a Composable function or a SwiftUI View, etc.), Decompose uses the Component concept for navigation. So the UI is only responsible for displaying the information, and everything else is behind the component boundary. This allows more code to be shared between platforms while keeping the UI layer thinner.

Additionally, this approach significantly simplifies testing. A component can be tested by a unit or integration test, often without instrumentation, which is fast and reliable. Plus, various tools can be used for UI testing, including but not limited to screenshot testing.

Component hierarchy

Pluggable UI hierarchy

Typical component structure

ComponentContext

Each component has an associated ComponentContext which implements the following interfaces:

  • LifecycleOwner, provided by Essenty library, so each component has its own lifecycle
  • StateKeeperOwner, provided by Essenty library, so you can preserve any state during configuration changes and/or process death
  • InstanceKeeperOwner, provided by Essenty library, so you can retain arbitrary object instances in your components (like with AndroidX ViewModels)
  • BackHandlerOwner, provided by Essenty library, so each component can handle back button events

So if a component requires any of the above features, just pass the ComponentContext via the component's constructor. You can use the delegation pattern to add the ComponentContext to this scope:

import com.arkivanov.decompose.ComponentContext

class RootComponent(
    componentContext: ComponentContext
) : ComponentContext by componentContext {

    // Some code here
}

It may also be useful to extract an interface, e.g. for creating separate implementations for Compose/SwiftUI previews, or writing test doubles.

import com.arkivanov.decompose.ComponentContext

interface RootComponent

class DefaultRootComponent(
    componentContext: ComponentContext
) : RootComponent, ComponentContext by componentContext {

    // Some code here
}

Root ComponentContext

When instantiating a root component, the ComponentContext should be created manually. There is DefaultComponentContext which is the default implementation class of the ComponentContext.

Warning

The root ComponentContext and the root component should be always created on the UI thread.

Root ComponentContext in Android

Decompose provides a few handy helper functions for creating the root ComponentContext in Android. The preferred way is to create the root ComponentContext in an Activity or a Fragment.

Root ComponentContext in Activity

For this case Decompose provides defaultComponentContext() extension function, which can be called in scope of an Activity.

Warning

The defaultComponentContext function must only be called once during the lifetime of the host Activity or Fragment, typically in onCreate. Calling it a second time will result in a crash.

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.arkivanov.decompose.defaultComponentContext

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val root = DefaultRootComponent(defaultComponentContext())
    }
}

Root ComponentContext in Fragment

Use defaultComponentContext(OnBackPressedDispatcher?) extension function, which can be called in scope of Fragment.

import android.os.Bundle
import androidx.fragment.app.Fragment
import com.arkivanov.decompose.defaultComponentContext

class SomeFragment : Fragment() {
    private lateinit var root: RootComponent

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        root =
            DefaultRootComponent(
                componentContext = defaultComponentContext(
                    onBackPressedDispatcher = requireActivity().onBackPressedDispatcher
                )
            )
    }
}

Root ComponentContext in Jetpack/JetBrains Compose

It is advised to not create the root ComponentContext (and a root component) directly in a Composable function. Compositions may be performed in a background thread, which may break things. The preferred way is to create the root component on the UI thread outside of Compose.

Warning

If you can't avoid creating the root component in a Composable function, please make sure you use remember. This will prevent the root component and its ComponentContext from being recreated on each composition.

Android with Compose

Prefer creating the root ComponentContext (and a root component) before starting Compose, e.g. in an Activity or a Fragment.

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import com.arkivanov.decompose.defaultComponentContext

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Create the root component before starting Compose
        val root = DefaultRootComponent(componentContext = defaultComponentContext())

        // Start Compose
        setContent {
            // The rest of the code
        }
    }
}

JVM/Desktop with Compose

Make sure you always create the root component on the UI thread. Please refer to samples for an example of runOnUiThread function .

import androidx.compose.ui.window.application
import com.arkivanov.decompose.DefaultComponentContext

fun main() {
    // Create the root component on the UI thread before starting Compose
    val root = runOnUiThread { DefaultRootComponent(componentContext = DefaultComponentContext(...)) }

    // Start Compose
    application {
        // The rest of the code
    }
}

Other platforms with Compose

Prefer creating the root ComponentContext (and a root component) before starting Compose, e.g. in directly in the main function.

import androidx.compose.ui.window.application
import com.arkivanov.decompose.DefaultComponentContext

fun main() {
    // Create the root component before starting Compose.
    // Make sure that this happens on the UI thread.
    val root = DefaultRootComponent(componentContext = DefaultComponentContext(...))

    // Start Compose
    application {
        // The rest of the code
    }
}

Value and MutableValue state holders

Value - is a multiplatform way to expose streams of states. It contains the value property, which always returns the current state. It also provides the ability to observe state changes via subscribe/unsubscribe methods. There is MutableValue which is a mutable variant of Value. Since Value is a class (not an interface) with a generic type parameter, it can be used to expose state streams to ObjC/Swift.

Using Value is not mandatory, you can use any other state holders, e.g. StateFlow, State, Observable, LiveData, etc.

If you are using Jetpack/JetBrains Compose, Value can be observed in Composable functions using one of the Compose extension modules.

Warning

Even though both Value and MutableValue are thread-safe, it's recommended to subscribe and update it only on the main thread.

Why not StateFlow?

Decompose uses Value to avoid dependency on Kotlin coroutines. One may prefer using Reaktive, RxJava, etc. instead of coroutines. It also provides better interoperability with ObjC/Swift and simplifies testing. Feel free to convert Value to StateFlow or any other state holder if you need it.

Examples

Simplest Component Example

Here is an example of simple Counter component:

import com.arkivanov.decompose.value.MutableValue
import com.arkivanov.decompose.value.Value
import com.arkivanov.decompose.value.update

class Counter {
    private val _state = MutableValue(State())
    val state: Value<State> = _state

    fun increment() {
        _state.update { it.copy(count = it.count + 1) }
    }

    data class State(val count: Int = 0)
}

Jetpack/JetBrains Compose UI Example

import androidx.compose.foundation.layout.Column
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState

@Composable
fun CounterContent(counter: Counter) {
    val state by counter.state.subscribeAsState()

    Column {
        Text(text = state.count.toString())

        Button(onClick = counter::increment) {
            Text("Increment")
        }
    }
}

SwiftUI Example

struct CounterView: View {
    private let counter: Counter

    @StateValue
    private var state: CounterState

    init(_ counter: Counter) {
        self.counter = counter
        _state = StateValue(counter.state)
    }

    var body: some View {
        VStack(spacing: 8) {
            Text(state.value.text)
            Button(action: counter.increment, label: { Text("Increment") })
        }
    }
}

What is StateValue

StateValue is a property wrapper for Value that makes it observable in SwiftUI. Unfortunately it does not look possible to publish utils for SwiftUI as a library or framework, so it has to be copied in your project.

More Swift utilities

You can find more useful utilities for SwiftUI in the DecomposeHelpers/ folder: