Image by Jorge Castro

Building a simple Web Application with Compose Multiplatform using Decompose

Jorge Luis Castro Medina
5 min readFeb 16, 2024

--

Hello everyone 👋🏻, today I want to share with you a very practical article, leaving theory aside to show you how to easily add Decompose to your Web project, step by step. 🚀

What is Decompose?

Decompose is a Kotlin Multiplatform library for breaking down your code into tree-structured lifecycle-aware business logic components (aka BLoC), with routing functionality and pluggable UI (Jetpack/Multiplatform Compose, Android Views, SwiftUI, Kotlin/React, etc.).

You can consult its official documentation for further details.

Let’s get to the point

First of all we are going to create a new project using Kotlin Multiplatform Wizard. For this example we just need to have web option selected, remember that due the date Compose Multiplatform for web is in experimental.

If you inspect the project content, including hidden files, you will notice references to Fleet, but you will still be able to open your project with Android Studio. Make sure you have configured the latter to work properly with Kotlin Multiplatform.

You can try the demo included in the default template by running the
You can see the demo that comes with the default template by executing the following command:

./gradlew :composeApp:wasmJsRun

Decompose adds support for Kotlin/Wasm starting from its version 3.0.0. As of now, the latest published version is 3.0.0-alpha07. Open your version catalog and add the following dependencies:

[versions]
# Other versions
decompose = "3.0.0-alpha07"


[libraries]
# Other libraries
decompose = { group = "com.arkivanov.decompose", name = "decompose", version.ref = "decompose" }
decompose-extensions-compose = { group = "com.arkivanov.decompose", name = "extensions-compose", version.ref = "decompose" }

Add the dependencies for Decompose

sourceSets {
commonMain.dependencies {
// Other dependencies
implementation(libs.decompose)
implementation(libs.decompose.extensions.compose)
}
}

Now we will create two packages called firstscreen and secondscreen where we will add the necessary files for each of our views for this example

Within the package firstscreen, we will add our first component to handle the events of our initial view.

FirstScreenComponent.kt

package firstscreen

import com.arkivanov.decompose.ComponentContext

interface FirstScreenComponent {
fun onNavigateToSecondScreen()
}

class DefaultFirstScreenComponent(
componentContext: ComponentContext,
private val onShowSecondScreenClicked: () -> Unit
): FirstScreenComponent, ComponentContext by componentContext {
override fun onNavigateToSecondScreen() {
onShowSecondScreenClicked()
}
}

Now we will create our first screen with the following code:

FirstScreen.kt

package firstscreen

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight

@Composable
fun FirstScreen(
component: FirstScreenComponent
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
) {
Column {
Text(
text = "First Screen",
fontWeight = FontWeight.Bold
)
Button(onClick = { component.onNavigateToSecondScreen() }) {
Text("Navigate to Second Screen")
}
}
}
}

Similarly, we will do with the second screen.

SecondScreenComponent.kt

package secondscreen

import com.arkivanov.decompose.ComponentContext

interface SecondScreenComponent {
fun onBackToFirstScreen()
}

class DefaultSecondScreenComponent(
componentContext: ComponentContext,
private val onBackToFirstScreenClicked: () -> Unit
): SecondScreenComponent, ComponentContext by componentContext {
override fun onBackToFirstScreen() {
onBackToFirstScreenClicked()
}
}

SecondScreen.kt

package secondscreen

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight

@Composable
fun SecondScreen(
component: SecondScreenComponent
) {

Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
) {
Column {
Text(
text = "Second Screen",
fontWeight = FontWeight.Bold
)
Button(onClick = { component.onBackToFirstScreen() }) {
Text("Navigate to First Screen")
}
}
}
}

Now we will create the entry point of our application, for which we will create a package called root and add the following 3 files:

RootComponent.kt

package root

import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.value.Value
import firstscreen.FirstScreenComponent
import secondscreen.SecondScreenComponent

internal interface RootComponent {

val stack: Value<ChildStack<*, Child>>

fun onBackClicked(toIndex: Int)

sealed class Child {
class Screen1(val component: FirstScreenComponent) : Child()
class Screen2(val component: SecondScreenComponent) : Child()
}
}

DefaultRootComponent.kt

package root

import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.popTo
import com.arkivanov.decompose.router.stack.push
import com.arkivanov.decompose.value.Value
import firstscreen.DefaultFirstScreenComponent
import firstscreen.FirstScreenComponent
import secondscreen.DefaultSecondScreenComponent
import secondscreen.SecondScreenComponent


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

private val navigation = StackNavigation<Config>()


override val stack: Value<ChildStack<*, RootComponent.Child>> =
childStack(
source = navigation,
serializer = null,
initialConfiguration = Config.Screen1,
handleBackButton = true,
childFactory = ::child,
)

override fun onBackClicked(toIndex: Int) {
navigation.popTo(index = toIndex)
}

private fun child(config: Config, childComponentContext: ComponentContext): RootComponent.Child =
when (config) {
is Config.Screen1 -> RootComponent.Child.Screen1(screen1Component(childComponentContext))
is Config.Screen2 -> RootComponent.Child.Screen2(screen2Component(childComponentContext))
}

private fun screen1Component(componentContext: ComponentContext): FirstScreenComponent =
DefaultFirstScreenComponent(
componentContext = componentContext,
onShowSecondScreenClicked = { navigation.push(Config.Screen2) },
)

private fun screen2Component(componentContext: ComponentContext): SecondScreenComponent =
DefaultSecondScreenComponent(
componentContext = componentContext,
onBackToFirstScreenClicked = navigation::pop,
)

private sealed interface Config {
data object Screen1 : Config
data object Screen2 : Config
}
}

Next, we will move the file App.kt into the root package and rename it to be called RootContent.kt (the name is only by convention), and modify its content with the following code:

RootContent.kt

package root

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.plus
import com.arkivanov.decompose.extensions.compose.stack.animation.scale
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import firstscreen.FirstScreen
import secondscreen.SecondScreen


@Composable
internal fun RootContent(
component: RootComponent
) {
MaterialTheme {
Children(
stack = component.stack,
modifier = Modifier.fillMaxSize(),
animation = stackAnimation(fade() + scale())
) {
when (val instance = it.instance) {
is RootComponent.Child.Screen1 -> FirstScreen(component = instance.component)
is RootComponent.Child.Screen2 -> SecondScreen(component = instance.component)
}
}
}
}

Finally, let’s modify the main.kt so that it looks like the following:

import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.CanvasBasedWindow
import com.arkivanov.decompose.DefaultComponentContext
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
import com.arkivanov.essenty.lifecycle.resume
import root.DefaultRootComponent
import root.RootContent

@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val lifecycle = LifecycleRegistry()

val root = DefaultRootComponent(
componentContext = DefaultComponentContext(lifecycle = lifecycle)
)

lifecycle.resume()

CanvasBasedWindow(canvasElementId = "ComposeTarget") { RootContent(root) }
}

Launch the application once more and observe the changes.

./gradlew :composeApp:wasmJsRun

If you like my content and want to support my work, you can give me a cup of coffee ☕️ 🥰

Follow me in

--

--

Jorge Luis Castro Medina

I'm a Software Engineer passionate about mobile technologies, and I like everything related to software design and architecture