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.
Mental model for React developers
Section titled “Mental model for React developers”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:
flowchart TB CMP["Compose Multiplatform"] CMP --> D["Desktop (JVM)"] CMP --> W["Web (Wasm)"] CMP --> I["iOS (native)"] CMP --> A["Android (ART)"] D --> UI["Shared Compose UI"] W --> UI I --> UI A --> UI UI --> LOGIC["Shared business logic (Kotlin common)"]
This module targets Desktop (JVM) and Web (Wasm).
Project setup
Section titled “Project setup”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.
pluginManagement { repositories { google() gradlePluginPortal() mavenCentral() }}
dependencyResolutionManagement { repositories { google() mavenCentral() }}
rootProject.name = "task-manager"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:
./gradlew desktopRun # Desktop app./gradlew wasmJsBrowserDevelopmentRun # Web dev server (hot reload)./gradlew wasmJsBrowserDistribution # Web production buildMost 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" />@Composablefun Greeting(name: String) { Text( text = "Hello, $name!", style = MaterialTheme.typography.headlineLarge )}
// UsageGreeting(name = "World")Key differences:
- No JSX — you call composable functions directly.
@Composablemarks 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.
Components with children (slots)
Section titled “Components with children (slots)”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>@Composablefun Card( title: String, content: @Composable () -> Unit // Slot = children) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) .background( MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp) ) ) { Text(title, style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(8.dp)) content() // Render children }}
// Usage — trailing lambda is the "children"Card(title = "Settings") { Text("Card content here")}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.
Conditional rendering
Section titled “Conditional rendering”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> )}@Composablefun UserBadge(user: User?) { if (user == null) { Text("Guest") return }
Row { Text(user.name) // smart-cast: user is non-null here if (user.isAdmin) { Text( text = "Admin", color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelSmall ) } }}Lists — map to items
Section titled “Lists — map to items”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> )}@Composablefun TaskList(tasks: List<Task>) { LazyColumn { items( items = tasks, key = { it.id } // Like React's key prop ) { task -> Text(task.title) } }}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"> |
@Composablefun 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 |
Building a form
Section titled “Building a form”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> )}@Composablefun CreateTaskForm(onSubmit: (NewTask) -> Unit) { var title by remember { mutableStateOf("") } var priority by remember { mutableStateOf(Priority.MEDIUM) } var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = title, onValueChange = { title = it }, label = { Text("Task title") }, modifier = Modifier.fillMaxWidth() )
ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = it } ) { OutlinedTextField( value = priority.name.lowercase().replaceFirstChar { it.uppercase() }, onValueChange = {}, readOnly = true, label = { Text("Priority") }, modifier = Modifier.menuAnchor().fillMaxWidth() ) ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { Priority.entries.forEach { p -> DropdownMenuItem( text = { Text(p.name.lowercase().replaceFirstChar { it.uppercase() }) }, onClick = { priority = p; expanded = false } ) } } }
Button( onClick = { onSubmit(NewTask(title = title.trim(), priority = priority)) }, enabled = title.isNotBlank(), modifier = Modifier.fillMaxWidth() ) { Text("Create") } }}A dropdown takes more explicit code than HTML’s <select>, but you get full
control over its appearance.
State management — useState to remember
Section titled “State management — useState to remember”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>}@Composablefun Counter() { var count by remember { mutableStateOf(0) } Button(onClick = { count++ }) { Text("Count: $count") }}| 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:
@Composablefun 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() } }}derivedStateOf — computed values
Section titled “derivedStateOf — computed values”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] ) // ...}@Composablefun TaskStats(tasks: List<Task>) { val completedCount by remember(tasks) { derivedStateOf { tasks.count { it.completed } } } val pendingCount by remember(tasks) { derivedStateOf { tasks.count { !it.completed } } }
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { StatCard("Completed", completedCount) StatCard("Pending", pendingCount) }}State hoisting — lifting state up
Section titled “State hoisting — lifting state up”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} /> </> )}@Composablefun Parent() { var filter by remember { mutableStateOf("") }
Column { SearchBar(value = filter, onValueChange = { filter = it }) ResultsList(filter = filter) }}
@Composablefun SearchBar(value: String, onValueChange: (String) -> Unit) { OutlinedTextField( value = value, onValueChange = onValueChange, label = { Text("Search") } )}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])
// ...}@Composablefun UserProfile(userId: String) { var user by remember { mutableStateOf<User?>(null) }
// Launches a coroutine. Cancels and re-launches when userId changes. LaunchedEffect(userId) { user = fetchUser(userId) // Suspend function — no callbacks }
// ...}DisposableEffect — cleanup on unmount
Section titled “DisposableEffect — cleanup on unmount”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])}@Composablefun WebSocketListener(url: String) { DisposableEffect(url) { val connection = createWebSocketConnection(url)
onDispose { connection.close() // Cleanup — like useEffect's return } }}| 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])
// ...}@Composablefun LiveData(endpoint: String) { var data by remember { mutableStateOf<ApiResponse?>(null) }
LaunchedEffect(endpoint) { while (isActive) { // Automatically false when cancelled data = httpClient.get(endpoint).body() delay(5_000) } }
// Render data...}Memoization — useMemo to remember(key)
Section titled “Memoization — useMemo to remember(key)”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>}@Composablefun ExpensiveList(items: List<Item>, filter: String) { val filtered = remember(items, filter) { items.filter { it.name.contains(filter) } }
LazyColumn { items(filtered, key = { it.id }) { item -> Text(item.name) } }}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>}// Define the CompositionLocal (like createContext)val LocalAppTheme = compositionLocalOf<AppTheme> { error("No AppTheme provided") // No default — must be provided}// Or with a default value:// val LocalAppTheme = staticCompositionLocalOf { AppTheme.Light }
@Composablefun App() { CompositionLocalProvider(LocalAppTheme provides AppTheme.Dark) { Dashboard() }}
@Composablefun DeepChild() { val theme = LocalAppTheme.current // Like useContext(ThemeContext) Text("Hello", color = theme.textColor)}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 rendersfunction TaskItem({ task }: { task: Task }) { /* ... */ }
// Only re-renders if task reference changesconst TaskItem = React.memo(({ task }: { task: Task }) => { /* ... */ })// The Compose compiler checks if Task is "stable".// If it is, this composable is automatically skipped when task hasn't changed.@Composablefun TaskItem(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 typesdata class Task( val id: String, val title: String, val completed: Boolean, val priority: Priority // Enum is stable)
// NOT stable — has a var propertydata class MutableTask(val id: String, var title: String)
// NOT stable — List could be mutable at runtimedata 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.
@Immutabledata class TaskGroup( val name: String, val tasks: List<Task> // You guarantee this list won't be mutated)
@Stableclass 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 })}))// UI State — sealed interface models all possible statessealed interface TaskUiState { data object Loading : TaskUiState data class Error(val message: String) : TaskUiState data class Success(val tasks: List<Task>, val filter: TaskFilter) : TaskUiState}
enum class TaskFilter { ALL, ACTIVE, COMPLETED }
class TaskViewModel(private val repository: TaskRepository) { private val _uiState = MutableStateFlow<TaskUiState>(TaskUiState.Loading) val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
private val _filter = MutableStateFlow(TaskFilter.ALL) private var tasks = listOf<Task>()
suspend fun fetchTasks() { _uiState.value = TaskUiState.Loading try { tasks = repository.getTasks() emitFiltered() } catch (e: Exception) { _uiState.value = TaskUiState.Error(e.message ?: "Unknown error") } }
suspend fun toggleTask(id: String) { val task = tasks.find { it.id == id } ?: return val updated = repository.updateTask(id, task.copy(completed = !task.completed)) tasks = tasks.map { if (it.id == id) updated else it } emitFiltered() }
fun setFilter(filter: TaskFilter) { _filter.value = filter emitFiltered() }
private fun emitFiltered() { val filtered = when (_filter.value) { TaskFilter.ALL -> tasks TaskFilter.ACTIVE -> tasks.filter { !it.completed } TaskFilter.COMPLETED -> tasks.filter { it.completed } } _uiState.value = TaskUiState.Success(filtered, _filter.value) }}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> )}@Composablefun TaskScreen(viewModel: TaskViewModel) { val uiState by viewModel.uiState.collectAsState() // subscribe like Zustand val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { viewModel.fetchTasks() } // fetch on first composition
when (val state = uiState) { is TaskUiState.Loading -> CircularProgressIndicator() is TaskUiState.Error -> ErrorMessage(state.message) is TaskUiState.Success -> { Column { FilterBar(current = state.filter, onChange = viewModel::setFilter) TaskList( tasks = state.tasks, onToggle = { id -> scope.launch { viewModel.toggleTask(id) } } ) } } }}| 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:
flowchart LR Store["Store / ViewModel"] -->|"state"| UI["UI layer (composables)"] UI -->|"events"| Store Store -->|"side effects"| API["API layer"]
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'] }) })}// State model — replaces React Query's { data, isLoading, error }sealed interface Resource<out T> { data object Loading : Resource<Nothing> data class Success<T>(val data: T) : Resource<T> data class Error(val message: String, val cause: Throwable? = null) : Resource<Nothing>}
val httpClient = HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } install(HttpTimeout) { requestTimeoutMillis = 30_000 } defaultRequest { url("http://localhost:8080/api/") contentType(ContentType.Application.Json) }}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. */@Composablefun <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@Composablefun 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.
Validation — Zod to Kotlin data classes
Section titled “Validation — Zod to Kotlin data classes”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 invalidconst result = TaskSchema.safeParse(jsonData) // { success, data, error }@Serializabledata class Task( val id: String, val title: String, val description: String? = null, // Optional with default val priority: Priority = Priority.MEDIUM, val completed: Boolean = false, val createdAt: Instant) { init { // Validation in the constructor — runs on every creation require(title.isNotBlank()) { "Title must not be blank" } require(title.length <= 255) { "Title must be 255 chars or less" } }}
@Serializableenum class Priority { LOW, MEDIUM, HIGH }
val json = Json { ignoreUnknownKeys = true }
// Throws on invalid JSON structure OR validation failureval task = json.decodeFromString<Task>(jsonString)
// Safe-parse equivalentfun <T> safeParse(deserializer: DeserializationStrategy<T>, jsonString: String): Result<T> = runCatching { json.decodeFromString(deserializer, jsonString) }| 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:
@Serializablesealed 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)}Navigation — React Router to Compose Navigation
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> )}// Routes as a sealed interface (type-safe)sealed interface Screen { data object TaskList : Screen data class TaskDetail(val taskId: String) : Screen data object Settings : Screen}
@Composablefun App() { var currentScreen by remember { mutableStateOf<Screen>(Screen.TaskList) }
when (val screen = currentScreen) { is Screen.TaskList -> TaskListScreen( onTaskClick = { id -> currentScreen = Screen.TaskDetail(id) }, onSettingsClick = { currentScreen = Screen.Settings } ) is Screen.TaskDetail -> TaskDetailScreen( taskId = screen.taskId, onBack = { currentScreen = Screen.TaskList } ) is Screen.Settings -> SettingsScreen( onBack = { currentScreen = Screen.TaskList } ) }}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>Column( modifier = Modifier .fillMaxWidth() .widthIn(max = 448.dp) // max-w-md ≈ 28rem ≈ 448dp .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp)) .shadow(4.dp, RoundedCornerShape(8.dp)) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text("Title", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) Text("Description", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)}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),)
@Composablefun 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.
Targeting web with Compose for Wasm
Section titled “Targeting web with Compose for Wasm”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.
import androidx.compose.ui.window.Windowimport androidx.compose.ui.window.application
fun main() = application { Window( onCloseRequest = ::exitApplication, title = "Task Manager" ) { App() }}import androidx.compose.ui.ExperimentalComposeUiApiimport androidx.compose.ui.window.CanvasBasedWindow
@OptIn(ExperimentalComposeUiApi::class)fun main() { CanvasBasedWindow(canvasElementId = "ComposeTarget", 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 declarationexpect fun openInBrowser(url: String)
// desktopMain — actual implementationactual fun openInBrowser(url: String) { java.awt.Desktop.getDesktop().browse(java.net.URI(url))}
// wasmJsMain — actual implementationactual 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.
Connecting to your backend API
Section titled “Connecting to your backend API”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:
@Serializabledata 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)
@Serializableenum class Priority { LOW, MEDIUM, HIGH }
@Serializabledata class CreateTaskRequest( val title: String, val description: String? = null, val priority: Priority = Priority.MEDIUM)
@Serializabledata 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:
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:
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:
@Composablefun 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 ) } }}
@Composablefun 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.
What you learned
Section titled “What you learned”| 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 |
Practice
Section titled “Practice”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.