Skip to content

Compose Multiplatform

If you write React — components, hooks, React Query, Zustand, Zod — this module maps every one of those concepts to its Compose Multiplatform equivalent. Compose Multiplatform is JetBrains’ UI framework, built on the same compiler and runtime as Jetpack Compose (Android), but targeting Desktop (JVM), Web (Kotlin/Wasm), and iOS. We focus on Desktop and Web — the targets most relevant to backend developers building admin dashboards, internal tools, and full-stack apps. No Android.

The whole module hangs on this one table. Everything below is an expansion of a row in it.

React ConceptCompose EquivalentNotes
Component (function)@Composable functionSame idea: UI = f(state)
JSXCompose DSL (Kotlin lambdas)No template language, just Kotlin
Virtual DOM + reconciliationCompose compiler + slot tableAutomatic recomposition on state change
useStateremember { mutableStateOf() }Survives recomposition
useEffectLaunchedEffect / DisposableEffectCoroutine-based side effects
useMemoremember(key) { computation }Recomputes when key changes
useContextCompositionLocalImplicit value propagation down the tree
React.memo@Stable / @ImmutableCompiler-level skip optimization
Zustand storeViewModel + StateFlowUnidirectional data flow
React QueryFlow + Ktor clientReactive data fetching
Zod schemaData class + kotlinx.serializationType-safe parsing + validation
React RouterCompose NavigationDeclarative routing
CSS/TailwindModifier chains + Material 3Composable styling
props.childrencontent: @Composable () -> UnitSlot APIs via trailing lambdas

The Compose stack layers shared logic under shared UI under per-platform targets:

Compose Multiplatform targets
Rendering diagram…

This module targets Desktop (JVM) and Web (Wasm).

Compose Multiplatform uses the JetBrains Compose Gradle plugin. Here’s the minimal setup for a Desktop + Web project — settings.gradle.kts adds the google() plugin repository, and build.gradle.kts declares the jvm("desktop") and wasmJs targets with their source sets.

settings.gradle.kts
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "task-manager"
build.gradle.kts
plugins {
kotlin("multiplatform") version "2.1.0"
id("org.jetbrains.compose") version "1.7.3"
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
}
kotlin {
jvm("desktop") // Desktop target (JVM)
wasmJs { browser() } // Web target (Kotlin/Wasm)
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.components.resources)
}
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
}
}
val wasmJsMain by getting {
dependencies {
// Web-specific dependencies if needed
}
}
}
}
compose.desktop {
application {
mainClass = "MainKt"
}
}

Run each target:

Terminal window
./gradlew desktopRun # Desktop app
./gradlew wasmJsBrowserDevelopmentRun # Web dev server (hot reload)
./gradlew wasmJsBrowserDistribution # Web production build

Most of your code lives in commonMain; each platform contributes only a tiny entry point.

  • Directorysrc/
    • DirectorycommonMain/kotlin/ shared UI + logic (90%+ of your code)
      • App.kt
      • Directoryui/
        • Directoryscreens/
        • Directorycomponents/
        • Directorytheme/
      • Directorydata/
        • Directorymodel/
        • Directoryrepository/
    • DirectorydesktopMain/kotlin/ desktop entry point
      • Main.kt
    • DirectorywasmJsMain/kotlin/ web entry point
      • Main.kt

The mapping from a React project’s layout is direct — components and pages move under ui/, the store becomes a ViewModel, the api/ layer becomes a repository:

ReactCompose
src/App.tsxsrc/commonMain/kotlin/App.kt
src/components/ui/components/
src/pages/ui/screens/
src/hooks/LaunchedEffect inline
src/store/data/viewmodel/
src/api/data/repository/
src/types/data/model/
src/index.tsxsrc/desktopMain/kotlin/Main.kt

Composable functions — React components in Kotlin

Section titled “Composable functions — React components in Kotlin”

A @Composable function is React’s function component. It takes typed parameters (props) and emits UI by calling other composables — there’s no JSX and no return value.

function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>
}
// Usage
<Greeting name="World" />

Key differences:

  • No JSX — you call composable functions directly.
  • @Composable marks a function as a UI builder (like React’s function components).
  • Parameters are regular Kotlin parameters (typed, nullable, with defaults).
  • No return value — composables emit UI by calling other composables.

React’s children becomes a content: @Composable () -> Unit parameter. Kotlin’s trailing-lambda syntax makes the call site read naturally.

function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
)
}
// Usage
<Card title="Settings">
<p>Card content here</p>
</Card>

Multiple slots are just multiple @Composable () -> Unit parameters — a header, a sidebar, and a content lambda map cleanly onto a React layout component that takes header, sidebar, and children props.

Null safety is built into the language: User? instead of User | null. After a null check, smart-casting makes user non-null automatically — no JSX ternaries needed, just standard if/when.

function UserBadge({ user }: { user: User | null }) {
if (!user) return <span>Guest</span>
return (
<div>
<span>{user.name}</span>
{user.isAdmin && <span className="badge">Admin</span>}
</div>
)
}

A LazyColumn is a built-in virtualized list (like react-window). Its items DSL takes a key that works exactly like React’s key prop for efficient diffing, so you never call .map().

function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>{task.title}</li>
))}
</ul>
)
}

The Compose DSL — JSX without the angle brackets

Section titled “The Compose DSL — JSX without the angle brackets”

Compose has three core layout containers that map onto CSS flexbox concepts:

ComposeCSS EquivalentReact + Tailwind
Columnflex-direction: column<div className="flex flex-col">
Rowflex-direction: row<div className="flex flex-row">
Boxposition: relative (stacking)<div className="relative">
@Composable
fun ExampleLayout() {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Title", style = MaterialTheme.typography.headlineMedium)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Star, contentDescription = "Rating")
Text("4.5")
}
Box(
modifier = Modifier.fillMaxWidth().height(200.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
Text("Loading...") // Stacked on top
}
}
}

The everyday HTML/MUI elements all have Material 3 counterparts:

React (HTML/MUI)Compose (Material 3)
<p>, <h1>, <span>Text(style = ...)
<button onClick={...}>Button(onClick = { ... })
<input value onChange>TextField(value, onValueChange)
<img src="..." />Image(painter, contentDescription)
<div>Box, Column, Row
<ul> / virtualized listLazyColumn
<a href="...">ClickableText or Modifier.clickable
<select>DropdownMenu + DropdownMenuItem
<input type="checkbox">Checkbox(checked, onCheckedChange)
MUI <Switch>Switch(checked, onCheckedChange)
<CircularProgress>CircularProgressIndicator()
<Snackbar>Snackbar / SnackbarHost
<Dialog>AlertDialog / Dialog
<Drawer>ModalNavigationDrawer

There’s no <form> element or submit event — you handle submission directly. OutlinedTextField is a controlled input (like React’s controlled components), and by delegation with mutableStateOf makes state feel like a local variable.

function CreateTaskForm({ onSubmit }: { onSubmit: (task: NewTask) => void }) {
const [title, setTitle] = useState('')
const [priority, setPriority] = useState<Priority>('medium')
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ title, priority }) }}>
<input value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="Task title" />
<select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button type="submit" disabled={!title.trim()}>Create</button>
</form>
)
}

A dropdown takes more explicit code than HTML’s <select>, but you get full control over its appearance.

useState becomes remember { mutableStateOf(...) }. The by keyword is Kotlin’s property delegation: it lets you read and write the state as if it were a plain variable.

function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
ConceptReactCompose
State containeruseState returns [value, setter]mutableStateOf returns MutableState<T>
Survival across re-rendersHook orderingremember block
Trigger re-renderCall setterMutate state value
BatchingAutomatic in event handlersAutomatic via snapshot system

Without delegation you read countState.value and write countState.value = 5; with by you just read count and write count = 5. Different state types use different builders:

@Composable
fun StateExamples() {
var name by remember { mutableStateOf("") } // String
var isChecked by remember { mutableStateOf(false) } // Boolean
var selectedItem by remember { mutableStateOf<Task?>(null) } // Nullable
val items = remember { mutableStateListOf<String>() } // Observable list
val cache = remember { mutableStateMapOf<String, Int>() } // Observable map
// Derived state — recomputed when dependencies change
val isValid by remember {
derivedStateOf { name.isNotBlank() && items.isNotEmpty() }
}
}

Where React reaches for useMemo to derive values from props, Compose uses derivedStateOf wrapped in a keyed remember — it recomputes only when the keyed input actually changes.

function TaskStats({ tasks }: { tasks: Task[] }) {
const completedCount = useMemo(
() => tasks.filter(t => t.completed).length,
[tasks]
)
const pendingCount = useMemo(
() => tasks.filter(t => !t.completed).length,
[tasks]
)
// ...
}

The pattern is identical to React: move state to the nearest common ancestor and pass value down and an onValueChange callback up.

function Parent() {
const [filter, setFilter] = useState('')
return (
<>
<SearchBar value={filter} onChange={setFilter} />
<ResultsList filter={filter} />
</>
)
}

Side effects — useEffect to LaunchedEffect

Section titled “Side effects — useEffect to LaunchedEffect”

LaunchedEffect(key) works like useEffect([dep]): it re-runs when the key changes. The crucial upgrade is that the block is a coroutine — you call suspend functions directly, and cancellation is automatic via structured concurrency, so there’s no manual cleanup boolean.

function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
let cancelled = false
fetchUser(userId).then(u => {
if (!cancelled) setUser(u)
})
return () => { cancelled = true }
}, [userId])
// ...
}

When you need teardown (closing a socket, removing a listener), DisposableEffect provides an onDispose block — Compose’s answer to useEffect’s cleanup return.

function WebSocketListener({ url }: { url: string }) {
useEffect(() => {
const ws = new WebSocket(url)
ws.onmessage = (e) => { /* handle */ }
return () => ws.close() // Cleanup
}, [url])
}
ReactComposeWhen to use
useEffect(fn, [])LaunchedEffect(Unit) { }Run once on mount
useEffect(fn, [dep])LaunchedEffect(dep) { }Re-run when dep changes
useEffect with cleanupDisposableEffect(key) { onDispose { } }Setup + teardown resources
useLayoutEffectSideEffect { }Run after every composition (rare)
useEffect for timerLaunchedEffect(Unit) { while(true) { delay(1000) } }Coroutine loop

A polling effect shows the payoff: no setInterval/clearInterval, no cleanup function — the coroutine is cancelled automatically when the composable leaves the composition or the key changes.

function LiveData({ endpoint }: { endpoint: string }) {
const [data, setData] = useState(null)
useEffect(() => {
const interval = setInterval(async () => {
const res = await fetch(endpoint)
setData(await res.json())
}, 5000)
return () => clearInterval(interval)
}, [endpoint])
// ...
}

A keyed remember is useMemo: the block reruns whenever any key changes, exactly like a dependency array.

function ExpensiveList({ items, filter }: { items: Item[]; filter: string }) {
const filtered = useMemo(
() => items.filter(i => i.name.includes(filter)),
[items, filter]
)
return <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>
}

You can pass multiple keys — remember(key1, key2, key3) { ... } recomputes when any of them changes.

Context — useContext to CompositionLocal

Section titled “Context — useContext to CompositionLocal”

A CompositionLocal is React Context. You define it, provide a value with CompositionLocalProvider, and read it with .current anywhere down the tree.

const ThemeContext = createContext<Theme>(defaultTheme)
function App() {
return (
<ThemeContext.Provider value={darkTheme}>
<Dashboard />
</ThemeContext.Provider>
)
}
function DeepChild() {
const theme = useContext(ThemeContext)
return <div style={{ color: theme.textColor }}>Hello</div>
}

There are two flavors, differing in how widely they trigger recomposition:

TypeWhen value changesReact equivalent
compositionLocalOfOnly recomposes consumersContext with selective re-rendering
staticCompositionLocalOfRecomposes entire subtreeStandard Context behavior

Use staticCompositionLocalOf for values that rarely change (themes, configuration); use compositionLocalOf for values that change frequently (user session, locale). Compose also ships built-in locals — LocalDensity.current, LocalFocusManager.current, LocalClipboardManager.current — similar to React’s built-in contexts. You can nest providers by passing several provides pairs to one CompositionLocalProvider.

Performance — React.memo to @Stable and @Immutable

Section titled “Performance — React.memo to @Stable and @Immutable”

In React, components re-render when their parent does unless wrapped in React.memo. Compose is smarter by default: the compiler decides whether a composable can be skipped based on its parameter types.

// Re-renders every time parent renders
function TaskItem({ task }: { task: Task }) { /* ... */ }
// Only re-renders if task reference changes
const TaskItem = React.memo(({ task }: { task: Task }) => { /* ... */ })

A type is stable if all its properties are val, all property types are themselves stable, and it’s built from primitives, String, or function types.

// Stable — all vals, all primitive/String/enum types
data class Task(
val id: String,
val title: String,
val completed: Boolean,
val priority: Priority // Enum is stable
)
// NOT stable — has a var property
data class MutableTask(val id: String, var title: String)
// NOT stable — List could be mutable at runtime
data class TaskGroup(val name: String, val tasks: List<Task>)

When the compiler can’t infer stability, annotate. @Immutable promises the object and all its properties are truly immutable after construction; @Stable promises changes are observable through Compose’s snapshot system.

@Immutable
data class TaskGroup(
val name: String,
val tasks: List<Task> // You guarantee this list won't be mutated
)
@Stable
class TaskStore {
var tasks by mutableStateOf(listOf<Task>())
private set
fun addTask(task: Task) {
tasks = tasks + task
}
}

State at scale — Zustand to ViewModel + StateFlow

Section titled “State at scale — Zustand to ViewModel + StateFlow”

A Zustand store becomes a ViewModel exposing a StateFlow. The store’s set(...) calls become _state.value = ..., and get() reads become _state.value.

const useTaskStore = create<TaskState>((set, get) => ({
tasks: [],
filter: 'all',
isLoading: false,
error: null,
fetchTasks: async () => {
set({ isLoading: true, error: null })
try {
const tasks = await api.getTasks()
set({ tasks, isLoading: false })
} catch (error) {
set({ error: error.message, isLoading: false })
}
},
toggleTask: async (id: string) => {
const task = get().tasks.find(t => t.id === id)
if (!task) return
const updated = await api.updateTask(id, { completed: !task.completed })
set({ tasks: get().tasks.map(t => t.id === id ? updated : t) })
},
setFilter: (filter: Filter) => set({ filter })
}))

In the UI, subscribing to a Zustand hook becomes viewModel.uiState.collectAsState(), and a when over the sealed state replaces the loading/error guards.

function TaskScreen() {
const { tasks, filter, isLoading, error, fetchTasks, setFilter, toggleTask } = useTaskStore()
useEffect(() => { fetchTasks() }, [])
if (isLoading) return <Spinner />
if (error) return <ErrorMessage message={error} />
return (
<div>
<FilterBar current={filter} onChange={setFilter} />
<TaskList tasks={tasks} onToggle={toggleTask} />
</div>
)
}
ZustandViewModel + StateFlow
create<State>((set, get) => {...})class ViewModel { MutableStateFlow(...) }
set({ key: value })_stateFlow.value = newState
get().field_stateFlow.value.field
useStore() hookviewModel.uiState.collectAsState()
Selectors: useStore(s => s.field)when expression + smart casting
Middleware (devtools, persist)Custom Flow operators
Multiple slicesMultiple StateFlows or combined state

Both follow the same unidirectional data flow — state flows down to the UI, events flow back up, side effects hit the API layer:

Unidirectional data flow
Rendering diagram…

Data fetching — React Query to Flow-based fetching

Section titled “Data fetching — React Query to Flow-based fetching”

React Query gives you { data, isLoading, error }, caching, and refetching. The Compose equivalent models those three states as a sealed Resource<T> and emits them from a Ktor-backed repository as a Flow.

function TaskList() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['tasks'],
queryFn: () => api.getTasks(),
staleTime: 30_000,
refetchInterval: 60_000,
})
const createMutation = useMutation({
mutationFn: (title: string) => api.createTask({ title }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] })
})
}

The repository exposes read operations as Flow<Resource<T>> — emit Loading, then Success or Error — and mutations as plain suspend functions:

class TaskRepository(private val client: HttpClient) {
fun getTasks(): Flow<Resource<List<Task>>> = flow {
emit(Resource.Loading)
try {
val tasks: List<Task> = client.get("tasks").body()
emit(Resource.Success(tasks))
} catch (e: Exception) {
emit(Resource.Error(e.message ?: "Failed to fetch tasks", e))
}
}
suspend fun createTask(request: CreateTaskRequest): Task =
client.post("tasks") { setBody(request) }.body()
suspend fun updateTask(id: String, request: UpdateTaskRequest): Task =
client.put("tasks/$id") { setBody(request) }.body()
suspend fun deleteTask(id: String) {
client.delete("tasks/$id")
}
}

You can wrap the collection in a reusable composable that feels like useQuery — keyed like useEffect, returning the current Resource<T>:

/** Compose equivalent of useQuery: collects a Flow<Resource<T>> into Compose state. */
@Composable
fun <T> useResource(
vararg keys: Any?,
fetch: () -> Flow<Resource<T>>
): Resource<T> {
var resource by remember { mutableStateOf<Resource<T>>(Resource.Loading) }
LaunchedEffect(*keys) {
fetch().collect { resource = it }
}
return resource
}
// Usage — feels like React Query
@Composable
fun TaskListScreen(repository: TaskRepository) {
val tasksResource = useResource(Unit) { repository.getTasks() }
when (tasksResource) {
is Resource.Loading -> LoadingIndicator()
is Resource.Error -> ErrorMessage(tasksResource.message)
is Resource.Success -> TaskList(tasks = tasksResource.data)
}
}

For refetchInterval, a polling variant loops with delay inside the effect; for useMutation, a helper returns a MutationState<T> plus a trigger lambda that launches the call in a rememberCoroutineScope() and reports loading/error/success.

A Zod schema becomes a @Serializable data class. Optionality is String? = null, defaults are constructor defaults, enums are enum class, and field-level constraints go in an init { require(...) } block.

const TaskSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(255),
description: z.string().optional(),
priority: z.enum(['low', 'medium', 'high']),
completed: z.boolean().default(false),
createdAt: z.string().datetime(),
})
type Task = z.infer<typeof TaskSchema>
const task = TaskSchema.parse(jsonData) // throws on invalid
const result = TaskSchema.safeParse(jsonData) // { success, data, error }
ZodKotlin
z.string()val field: String
z.string().optional()val field: String? = null
z.number().int()val field: Int
z.boolean().default(false)val field: Boolean = false
z.enum([...])enum class
z.string().min(1).max(255)init { require(field.length in 1..255) }
z.string().email()Custom validation in init block
z.object({...})data class
z.array(z.string())val field: List<String>
z.union([...])sealed interface
z.infer<typeof Schema>Type is the class itself
schema.parse(data)json.decodeFromString<T>(str)
schema.safeParse(data)runCatching { decode(...) }

For chained, Zod-like validation you can build a tiny DSL — a Validator<T> that collects errors via check(condition, message) and returns a ValidationResult sealed interface — then call it on field change in a form, setting isError and supportingText on the OutlinedTextField.

Zod’s discriminatedUnion maps directly onto a @Serializable sealed interface with @SerialName-tagged subtypes; kotlinx.serialization picks the right subtype on decode, and a when over it is checked for exhaustiveness by the compiler:

@Serializable
sealed interface Notification {
@Serializable @SerialName("email")
data class Email(val to: String, val subject: String) : Notification
@Serializable @SerialName("sms")
data class Sms(val phone: String, val message: String) : Notification
@Serializable @SerialName("push")
data class Push(val deviceId: String, val title: String) : Notification
}
when (notification) {
is Notification.Email -> sendEmail(notification.to, notification.subject)
is Notification.Sms -> sendSms(notification.phone, notification.message)
is Notification.Push -> sendPush(notification.deviceId, notification.title)
}
Section titled “Navigation — React Router to Compose Navigation”

Define routes as a type-safe sealed interface. Route params become fields on the screen data class, and a top-level when renders the current screen — no library needed for basic cases.

function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<TaskList />} />
<Route path="/tasks/:id" element={<TaskDetail />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
)
}

For a real back stack, wrap a mutableStateListOf<Screen> in a small Navigator with navigate(screen) and goBack(), remember it with rememberNavigator(), and drive an AnimatedContent off navigator.currentScreen for transitions.

React RouterCompose Navigation
<Route path="/tasks/:id">data class TaskDetail(val taskId: String) : Screen
useParams()Parameters are fields on the screen data class
useNavigate()navigator.navigate(Screen.TaskDetail(id))
navigate(-1) / back buttonnavigator.goBack()
<Link to="/...">Modifier.clickable { navigator.navigate(...) }
Route guardsif (!isAuthenticated) navigate(Screen.Login)
Nested routesNested when or sub-navigators

Styling — CSS/Tailwind to Modifier chains + Material 3

Section titled “Styling — CSS/Tailwind to Modifier chains + Material 3”

In Compose, Modifier is the universal styling mechanism — it replaces CSS, Tailwind classes, and inline styles in one chainable API.

<div className="flex flex-col items-center p-4 gap-2 bg-white rounded-lg shadow-md w-full max-w-md">
<h2 className="text-xl font-bold text-gray-800">Title</h2>
<p className="text-sm text-gray-500">Description</p>
</div>

The Tailwind-to-Modifier mapping is mostly mechanical:

CSS / TailwindCompose Modifier
width: 100%.fillMaxWidth()
height: 100vh.fillMaxHeight()
w-48 (fixed width).width(192.dp)
max-w-md.widthIn(max = 448.dp)
p-4.padding(16.dp)
px-4 py-2.padding(horizontal = 16.dp, vertical = 8.dp)
m-4No direct margin — use Arrangement.spacedBy or Spacer
bg-white.background(Color.White)
rounded-lg.clip(RoundedCornerShape(8.dp))
shadow-md.shadow(4.dp, RoundedCornerShape(8.dp))
border border-gray-200.border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
cursor-pointer / onClick.clickable { }
opacity-50.alpha(0.5f)
overflow-hidden.clip(...)
absolute top-0 right-0Box { ... Modifier.align(Alignment.TopEnd) }
z-10.zIndex(10f)
transition-allanimateDpAsState, animateColorAsState
hover:bg-gray-100.hoverable() + interactionSource

A theme provider becomes a MaterialTheme wrapper that supplies a color scheme and typography. Children read values via MaterialTheme.colorScheme.* and MaterialTheme.typography.*:

private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color(0xFFFFFFFF),
background = Color(0xFFFEF7FF),
surface = Color(0xFFFEF7FF),
error = Color(0xFFB3261E),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
background = Color(0xFF1C1B1F),
surface = Color(0xFF1C1B1F),
error = Color(0xFFF2B8B5),
)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = AppTypography,
content = content
)
}

On desktop you can wire up hover: states with a MutableInteractionSource, collectIsHoveredAsState(), and animateColorAsState to animate the background on hover.

Compose for Web compiles your Kotlin to WebAssembly and renders to a <canvas> element. There’s no DOM manipulation — the same @Composable functions run pixel-identical on desktop and web. Each platform supplies only a thin entry point.

desktopMain/kotlin/Main.kt
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Task Manager"
) {
App()
}
}

When you genuinely need platform differences, use expect/actual declarations — declare the signature in commonMain and implement it per target:

// commonMain — expect declaration
expect fun openInBrowser(url: String)
// desktopMain — actual implementation
actual fun openInBrowser(url: String) {
java.awt.Desktop.getDesktop().browse(java.net.URI(url))
}
// wasmJsMain — actual implementation
actual fun openInBrowser(url: String) {
kotlinx.browser.window.open(url, "_blank")
}

The same trick gives you a currentPlatform value you can branch on inside shared composables to render a desktop-vs-web layout.

This is where it all comes together: a Compose UI talking to the REST API from the Spring Boot and Ktor modules. Start with @Serializable data models that mirror the API’s JSON:

commonMain/kotlin/data/model/Task.kt
@Serializable
data class Task(
val id: String,
val title: String,
val description: String? = null,
val priority: Priority = Priority.MEDIUM,
val completed: Boolean = false,
val createdAt: String? = null
)
@Serializable
enum class Priority { LOW, MEDIUM, HIGH }
@Serializable
data class CreateTaskRequest(
val title: String,
val description: String? = null,
val priority: Priority = Priority.MEDIUM
)
@Serializable
data class UpdateTaskRequest(
val title: String? = null,
val description: String? = null,
val priority: Priority? = null,
val completed: Boolean? = null
)

A thin TaskApi wraps the Ktor HttpClient with one suspend function per endpoint:

commonMain/kotlin/data/api/TaskApi.kt
class TaskApi(private val baseUrl: String = "http://localhost:8080") {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; isLenient = true })
}
install(HttpTimeout) { requestTimeoutMillis = 15_000 }
}
suspend fun getTasks(): List<Task> = client.get("$baseUrl/api/tasks").body()
suspend fun getTask(id: String): Task = client.get("$baseUrl/api/tasks/$id").body()
suspend fun createTask(request: CreateTaskRequest): Task =
client.post("$baseUrl/api/tasks") { setBody(request) }.body()
suspend fun updateTask(id: String, request: UpdateTaskRequest): Task =
client.put("$baseUrl/api/tasks/$id") { setBody(request) }.body()
suspend fun deleteTask(id: String) { client.delete("$baseUrl/api/tasks/$id") }
}

The ViewModel owns its own CoroutineScope, exposes a StateFlow<TaskListState>, and reloads the list after each mutation:

commonMain/kotlin/data/viewmodel/TaskViewModel.kt
sealed interface TaskListState {
data object Loading : TaskListState
data class Success(val tasks: List<Task>) : TaskListState
data class Error(val message: String) : TaskListState
}
class TaskViewModel(private val api: TaskApi) {
private val _state = MutableStateFlow<TaskListState>(TaskListState.Loading)
val state: StateFlow<TaskListState> = _state.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun loadTasks() {
scope.launch {
_state.value = TaskListState.Loading
try {
_state.value = TaskListState.Success(api.getTasks())
} catch (e: Exception) {
_state.value = TaskListState.Error(e.message ?: "Unknown error")
}
}
}
fun createTask(request: CreateTaskRequest) {
scope.launch {
try {
api.createTask(request)
loadTasks() // Refresh list
} catch (e: Exception) {
_state.value = TaskListState.Error(e.message ?: "Failed to create task")
}
}
}
fun toggleTask(task: Task) {
scope.launch {
api.updateTask(task.id, UpdateTaskRequest(completed = !task.completed))
loadTasks()
}
}
fun deleteTask(id: String) {
scope.launch { api.deleteTask(id); loadTasks() }
}
}

The root App composable wires it together: remember the API and ViewModel, collect the state, fetch on first composition, and render under an AppTheme. A Scaffold supplies the top bar and floating action button, and a when over the state drives the body:

commonMain/kotlin/App.kt
@Composable
fun App() {
val api = remember { TaskApi() }
val viewModel = remember { TaskViewModel(api) }
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) { viewModel.loadTasks() }
AppTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
TaskApp(
state = state,
onCreateTask = viewModel::createTask,
onToggleTask = viewModel::toggleTask,
onDeleteTask = viewModel::deleteTask,
onRefresh = viewModel::loadTasks
)
}
}
}
@Composable
fun TaskApp(
state: TaskListState,
onCreateTask: (CreateTaskRequest) -> Unit,
onToggleTask: (Task) -> Unit,
onDeleteTask: (String) -> Unit,
onRefresh: () -> Unit
) {
var showCreateDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Task Manager") },
actions = {
IconButton(onClick = onRefresh) { Icon(Icons.Default.Refresh, "Refresh") }
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreateDialog = true }) {
Icon(Icons.Default.Add, "Add task")
}
}
) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
when (state) {
is TaskListState.Loading ->
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
is TaskListState.Error ->
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
Text(state.message, color = MaterialTheme.colorScheme.error)
Button(onClick = onRefresh) { Text("Retry") }
}
is TaskListState.Success ->
if (state.tasks.isEmpty()) {
Text("No tasks yet. Create one!", modifier = Modifier.align(Alignment.Center))
} else {
TaskList(tasks = state.tasks, onToggle = onToggleTask, onDelete = onDeleteTask)
}
}
}
}
if (showCreateDialog) {
CreateTaskDialog(
onDismiss = { showCreateDialog = false },
onCreate = { request -> onCreateTask(request); showCreateDialog = false }
)
}
}

Each task renders as a Card with a Checkbox, a title (struck through when completed), an optional description, a priority badge, and a delete IconButton — the TaskCard, PriorityBadge, and CreateTaskDialog composables fill in the rest in the worked solution.

React ConceptCompose EquivalentKey Insight
Components@Composable functionsSame mental model, different syntax
JSXCompose DSL (Kotlin lambdas)No template language — just Kotlin
useStateremember { mutableStateOf() }by delegation makes it feel like variables
useEffectLaunchedEffectCoroutine-based, auto-cancelled
useMemoremember(key) { }Same dependency-keyed memoization
useContextCompositionLocalSame implicit value propagation
React.memo@Stable / @ImmutableCompiler handles most cases automatically
ZustandViewModel + StateFlowSame UDF pattern, Kotlin coroutines
React QueryFlow + Ktor clientBuild your own or use the patterns shown
ZodData class + init + serializationType system does most of the work
React RouterSealed interface + stateType-safe navigation
CSS/TailwindModifier chains + Material 3Composable styling, no CSS files

Put it all together — build the Task Manager as a real Compose Multiplatform app that runs on both desktop and the web, talking to the REST API from earlier modules.