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 Concept Compose Equivalent Notes
Component (function) @Composable function Same idea: UI = f(state)
JSX Compose DSL (Kotlin lambdas) No template language, just Kotlin
Virtual DOM + reconciliation Compose compiler + slot table Automatic recomposition on state change
useState remember { mutableStateOf() } Survives recomposition
useEffect LaunchedEffect / DisposableEffect Coroutine-based side effects
useMemo remember(key) { computation } Recomputes when key changes
useContext CompositionLocal Implicit value propagation down the tree
React.memo @Stable / @Immutable Compiler-level skip optimization
Zustand store ViewModel + StateFlow Unidirectional data flow
React Query Flow + Ktor client Reactive data fetching
Zod schema Data class + kotlinx.serialization Type-safe parsing + validation
React Router Compose Navigation Declarative routing
CSS/Tailwind Modifier chains + Material 3 Composable styling
props.children content: @Composable () -> Unit Slot 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:

React Compose
src/App.tsx src/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.tsx src/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:

Compose CSS Equivalent React + Tailwind
Column flex-direction: column <div className="flex flex-col">
Row flex-direction: row <div className="flex flex-row">
Box position: 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 list LazyColumn
<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>
}
Concept React Compose
State container useState returns [value, setter] mutableStateOf returns MutableState<T>
Survival across re-renders Hook ordering remember block
Trigger re-render Call setter Mutate state value
Batching Automatic in event handlers Automatic 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])
}
React Compose When to use
useEffect(fn, []) LaunchedEffect(Unit) { } Run once on mount
useEffect(fn, [dep]) LaunchedEffect(dep) { } Re-run when dep changes
useEffect with cleanup DisposableEffect(key) { onDispose { } } Setup + teardown resources
useLayoutEffect SideEffect { } Run after every composition (rare)
useEffect for timer LaunchedEffect(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:

Type When value changes React equivalent
compositionLocalOf Only recomposes consumers Context with selective re-rendering
staticCompositionLocalOf Recomposes entire subtree Standard 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>
)
}
Zustand ViewModel + StateFlow
create<State>((set, get) => {...}) class ViewModel { MutableStateFlow(...) }
set({ key: value }) _stateFlow.value = newState
get().field _stateFlow.value.field
useStore() hook viewModel.uiState.collectAsState()
Selectors: useStore(s => s.field) when expression + smart casting
Middleware (devtools, persist) Custom Flow operators
Multiple slices Multiple 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 }
Zod Kotlin
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 Router Compose 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 button navigator.goBack()
<Link to="/..."> Modifier.clickable { navigator.navigate(...) }
Route guards if (!isAuthenticated) navigate(Screen.Login)
Nested routes Nested 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 / Tailwind Compose 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-4 No 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-0 Box { ... Modifier.align(Alignment.TopEnd) }
z-10 .zIndex(10f)
transition-all animateDpAsState, 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 Concept Compose Equivalent Key Insight
Components @Composable functions Same mental model, different syntax
JSX Compose DSL (Kotlin lambdas) No template language — just Kotlin
useState remember { mutableStateOf() } by delegation makes it feel like variables
useEffect LaunchedEffect Coroutine-based, auto-cancelled
useMemo remember(key) { } Same dependency-keyed memoization
useContext CompositionLocal Same implicit value propagation
React.memo @Stable / @Immutable Compiler handles most cases automatically
Zustand ViewModel + StateFlow Same UDF pattern, Kotlin coroutines
React Query Flow + Ktor client Build your own or use the patterns shown
Zod Data class + init + serialization Type system does most of the work
React Router Sealed interface + state Type-safe navigation
CSS/Tailwind Modifier chains + Material 3 Composable 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.