HTTP & APIs in Compose Desktop: Ktor Client & JSON Serialization | Kotlin Desktop #8
Video: HTTP & APIs in Compose Desktop: Ktor Client & JSON Serialization | Kotlin Desktop #8 by Taught by Celeste AI - AI Coding Coach
HTTP and APIs in Compose Desktop: Build a Weather App
Ktor Client for the HTTP. kotlinx.serialization for JSON.
LaunchedEffectandrememberCoroutineScopeto bridge async to Compose.
Today's app fetches real data from a real API. Pick a city, see its current weather — temperature, wind speed, weather code — pulled live from Open-Meteo (free, no key required).
The new pieces:
- Ktor Client — JetBrains' multiplatform HTTP client. Coroutine-based, JSON-aware via kotlinx.serialization.
LaunchedEffect— run a coroutine when a Composable enters composition.rememberCoroutineScope— get a scope you can launch into from event handlers.
What we are building
A 900×600 window with:
- A city selector (Tokyo / London / New York buttons) plus a refresh icon.
- A loading spinner while fetching.
- A weather card showing temperature, wind speed, weather code, and timestamp.
- An error message if the request fails.
Project layout
src/main/kotlin/
├── Main.kt # window
├── AppContent.kt # state machine + UI
├── WeatherApi.kt # Ktor client + fetchWeather
└── WeatherModels.kt # @Serializable response types
build.gradle.kts dependencies
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation("io.ktor:ktor-client-core:2.3.12")
implementation("io.ktor:ktor-client-cio:2.3.12")
implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
Four Ktor artefacts. core is the API. cio is the JVM engine (CIO = Coroutine-based I/O). content-negotiation plus kotlinx-json plug in JSON parsing.
Don't forget the kotlin("plugin.serialization") plugin in the plugins block — without it, @Serializable annotations are ignored.
WeatherModels.kt: response types
@Serializable
data class WeatherResponse(
val latitude: Double,
val longitude: Double,
val current: CurrentWeather,
)
@Serializable
data class CurrentWeather(
val time: String,
@SerialName("temperature_2m") val temperature: Double,
@SerialName("weather_code") val weatherCode: Int,
@SerialName("wind_speed_10m") val windSpeed: Double,
)
The shape of the JSON Open-Meteo returns. @SerialName maps the API's snake_case keys to Kotlin's idiomatic camelCase fields.
@Serializable is doing the work — at compile time, the kotlinx.serialization plugin generates serialize and deserialize code. No reflection, no runtime cost.
WeatherApi.kt: the Ktor client
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
data class City(val name: String, val latitude: Double, val longitude: Double)
val cities = listOf(
City("Tokyo", 35.6762, 139.6503),
City("London", 51.5074, -0.1278),
City("New York", 40.7128, -74.0060),
)
suspend fun fetchWeather(city: City): WeatherResponse {
return client.get("https://api.open-meteo.com/v1/forecast") {
parameter("latitude", city.latitude)
parameter("longitude", city.longitude)
parameter("current", "temperature_2m,weather_code,wind_speed_10m")
}.body()
}
Three pieces.
HttpClient(CIO) { install(ContentNegotiation) { json(...) } } builds a Ktor client with the CIO engine and JSON content negotiation. ignoreUnknownKeys = true is forgiving — if Open-Meteo adds new fields to the response, we won't crash.
fetchWeather(city) is suspend, so it can await async work. Inside, client.get(url) { parameter(...) } builds a request with query parameters. The .body() call parses the response JSON into our WeatherResponse type — Ktor uses the registered JSON encoder and the @Serializable annotation to deserialise.
The actual URL becomes:
https://api.open-meteo.com/v1/forecast?latitude=35.6762&longitude=139.6503¤t=temperature_2m,weather_code,wind_speed_10m
AppContent.kt: bridging to Compose
@Composable
fun AppContent() {
var selectedCity by remember { mutableStateOf(cities[0]) }
var weather by remember { mutableStateOf<WeatherResponse?>(null) }
var loading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
fun loadWeather(city: City) {
loading = true
error = null
scope.launch {
try {
weather = fetchWeather(city)
} catch (e: Exception) {
error = "Failed to fetch weather: ${e.message}"
} finally {
loading = false
}
}
}
LaunchedEffect(Unit) { loadWeather(selectedCity) }
// UI...
}
Four state pieces:
selectedCity— which city's weather to show.weather— the loaded data, or null while loading/error.loading— true while a request is in flight.error— error message string, or null.
This is the async-state pattern — every async fetch has these four states. Whatever your data source, the pattern is the same.
val scope = rememberCoroutineScope() gets a coroutine scope tied to this Composable's lifetime. When the Composable leaves composition, the scope cancels — pending requests stop.
fun loadWeather(city) is a regular function that uses scope.launch { ... } to fire a coroutine. The coroutine awaits fetchWeather, sets state on success or error, clears loading in finally. Standard try/catch/finally for async UX.
LaunchedEffect for initial load
LaunchedEffect(Unit) { loadWeather(selectedCity) }
LaunchedEffect(key) runs a suspending lambda when the Composable first enters composition. If key changes, the previous coroutine is cancelled and a new one starts. Passing Unit means "run once, never re-trigger."
For "load when this state changes," use the state as the key:
LaunchedEffect(selectedCity) { loadWeather(selectedCity) }
That would fetch on city changes automatically — but in this app we trigger fetches imperatively from button clicks, so we use Unit for the initial load only.
Rendering based on state
when {
loading -> CircularProgressIndicator()
error != null -> Text(error!!, color = MaterialTheme.colorScheme.error)
weather != null -> WeatherCard(weather!!)
}
A three-branch when:
- Loading: spinner.
- Error: message in error colour.
- Loaded: the weather card.
The else case (none of the above) renders nothing — fine when those states never co-occur.
For more elaborate UIs, model the state explicitly:
sealed class WeatherUiState {
object Initial : WeatherUiState()
object Loading : WeatherUiState()
data class Error(val message: String) : WeatherUiState()
data class Success(val data: WeatherResponse) : WeatherUiState()
}
Then var state by remember { mutableStateOf<WeatherUiState>(Initial) } and when (state) { ... }. Type-safe; the compiler enforces exhaustiveness.
WeatherCard
@Composable
fun WeatherCard(data: WeatherResponse) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Temperature: ${data.current.temperature}°C", style = MaterialTheme.typography.headlineMedium)
Text("Wind Speed: ${data.current.windSpeed} km/h", style = MaterialTheme.typography.bodyLarge)
Text("Weather Code: ${data.current.weatherCode}", style = MaterialTheme.typography.bodyLarge)
Text("Time: ${data.current.time}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Pure render. Takes the response, lays it out. No state, no callbacks.
Error handling beyond the basics
The simple error = e.message catches anything thrown. Real apps want more granular handling:
try {
weather = fetchWeather(city)
} catch (e: ResponseException) {
error = "API error ${e.response.status.value}"
} catch (e: ConnectTimeoutException) {
error = "Network timeout. Check your connection."
} catch (e: SerializationException) {
error = "Couldn't parse response."
} catch (e: Exception) {
error = "Unexpected: ${e.message}"
}
Specific messages for specific failures. The user knows whether to retry, check their network, or report a bug.
Cancelling requests
When the user clicks a different city while a request is in flight, you want to cancel the previous one. The simplest fix: store the Job and cancel it.
var currentJob by remember { mutableStateOf<Job?>(null) }
fun loadWeather(city: City) {
currentJob?.cancel()
currentJob = scope.launch {
// fetch
}
}
For more elaborate cases, use Flow with flatMapLatest or migrate to a real ViewModel pattern (Lesson 16).
Common mistakes
Calling fetchWeather directly from a Composable body. It's a suspend function; you can't call it from sync code. Use LaunchedEffect or scope.launch.
Forgetting ignoreUnknownKeys. Without it, the API adding a new field crashes your app. Always set it true unless you specifically want strict parsing.
Hardcoding the API URL. For dev/staging/prod environments, externalise.
No timeout. Ktor has reasonable defaults but they're long. For UX, set explicit timeouts: install(HttpTimeout) { requestTimeoutMillis = 5_000 }.
Hitting an API thousands of times in a tight loop. Rate limits, costs, performance. Debounce inputs that trigger requests.
What's next
Lesson 9: database and storage. Persist data locally with Exposed ORM and SQLite. Same pattern (suspend operations, state, error handling) applied to local CRUD instead of remote HTTP.
Recap
Ktor HttpClient(CIO) plus ContentNegotiation plus kotlinx.serialization for typed JSON responses. @SerialName for snake_case → camelCase field mapping. rememberCoroutineScope() plus scope.launch { ... } for triggering async work from event handlers. LaunchedEffect(key) for "fetch when this Composable enters composition." Three-state UI: loading, error, loaded.
Next lesson: database and storage.