Compose + Retrofit + Coroutines — Full API Integration
Build a complete API integration in Jetpack Compose — Retrofit setup, coroutine-based calls, ViewModel state management, error handling, loading states, and pull-to-refresh.
This is Part 9 of a 10-part series on Jetpack Compose.
- Your First Screen
- State Management
- Navigation
- Lists
- Theming
- Forms
- Side Effects
- Custom Layouts
- API Integration (this post)
- Migration from XML
This post ties together everything from the series — Retrofit for networking, coroutines for async, ViewModel for state, and Compose for UI.
Dependencies
dependencies {
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// ViewModel + Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
}
API service
Define your Retrofit interface with suspend functions:
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): List<PostDto>
@GET("posts/{id}")
suspend fun getPost(@Path("id") id: String): PostDto
@POST("posts")
suspend fun createPost(@Body request: CreatePostRequest): PostDto
@PUT("posts/{id}")
suspend fun updatePost(@Path("id") id: String, @Body request: UpdatePostRequest): PostDto
@DELETE("posts/{id}")
suspend fun deletePost(@Path("id") id: String)
}
Data models
import kotlinx.serialization.Serializable
@Serializable
data class PostDto(
val id: String,
val title: String,
val body: String,
val author: String,
val createdAt: String
)
@Serializable
data class CreatePostRequest(
val title: String,
val body: String
)
@Serializable
data class UpdatePostRequest(
val title: String,
val body: String
)
// Domain model (what the UI uses)
data class Post(
val id: String,
val title: String,
val body: String,
val author: String,
val createdAt: String
)
fun PostDto.toDomain() = Post(
id = id,
title = title,
body = body,
author = author,
createdAt = createdAt
)
Retrofit setup
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
private val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
val postApi: PostApi = retrofit.create(PostApi::class.java)
}
Repository
Wrap API calls with error handling:
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int? = null) : ApiResult<Nothing>()
}
class PostRepository(private val api: PostApi) {
suspend fun getPosts(page: Int = 1): ApiResult<List<Post>> {
return try {
val posts = api.getPosts(page = page).map { it.toDomain() }
ApiResult.Success(posts)
} catch (e: retrofit2.HttpException) {
ApiResult.Error("Server error: ${e.code()}", e.code())
} catch (e: java.io.IOException) {
ApiResult.Error("Network error. Check your connection.")
} catch (e: Exception) {
ApiResult.Error("Unexpected error: ${e.message}")
}
}
suspend fun getPost(id: String): ApiResult<Post> {
return try {
ApiResult.Success(api.getPost(id).toDomain())
} catch (e: retrofit2.HttpException) {
if (e.code() == 404) ApiResult.Error("Post not found")
else ApiResult.Error("Server error: ${e.code()}", e.code())
} catch (e: java.io.IOException) {
ApiResult.Error("Network error. Check your connection.")
}
}
suspend fun createPost(title: String, body: String): ApiResult<Post> {
return try {
val post = api.createPost(CreatePostRequest(title, body)).toDomain()
ApiResult.Success(post)
} catch (e: retrofit2.HttpException) {
ApiResult.Error("Failed to create post: ${e.code()}", e.code())
} catch (e: java.io.IOException) {
ApiResult.Error("Network error. Check your connection.")
}
}
suspend fun deletePost(id: String): ApiResult<Unit> {
return try {
api.deletePost(id)
ApiResult.Success(Unit)
} catch (e: Exception) {
ApiResult.Error("Failed to delete: ${e.message}")
}
}
}
ViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class PostListState(
val posts: List<Post> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null
)
class PostListViewModel(
private val repository: PostRepository = PostRepository(ApiClient.postApi)
) : ViewModel() {
private val _state = MutableStateFlow(PostListState())
val state: StateFlow<PostListState> = _state.asStateFlow()
init {
loadPosts()
}
fun loadPosts() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
when (val result = repository.getPosts()) {
is ApiResult.Success -> {
_state.value = PostListState(posts = result.data)
}
is ApiResult.Error -> {
_state.value = _state.value.copy(
isLoading = false,
error = result.message
)
}
}
}
}
fun refresh() {
viewModelScope.launch {
_state.value = _state.value.copy(isRefreshing = true, error = null)
when (val result = repository.getPosts()) {
is ApiResult.Success -> {
_state.value = PostListState(posts = result.data)
}
is ApiResult.Error -> {
_state.value = _state.value.copy(
isRefreshing = false,
error = result.message
)
}
}
}
}
fun deletePost(id: String) {
viewModelScope.launch {
when (repository.deletePost(id)) {
is ApiResult.Success -> {
_state.value = _state.value.copy(
posts = _state.value.posts.filter { it.id != id }
)
}
is ApiResult.Error -> {
// Show error via snackbar
}
}
}
}
}
Compose UI
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun PostListScreen(
viewModel: PostListViewModel = viewModel(),
onPostClick: (String) -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when {
state.isLoading && state.posts.isEmpty() -> {
LoadingScreen()
}
state.error != null && state.posts.isEmpty() -> {
ErrorScreen(
message = state.error!!,
onRetry = { viewModel.loadPosts() }
)
}
else -> {
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = { viewModel.refresh() }
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.posts, key = { it.id }) { post ->
PostCard(
post = post,
onClick = { onPostClick(post.id) }
)
}
}
}
}
}
}
@Composable
fun PostCard(post: Post, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = post.title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = "by ${post.author}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = post.body.take(100) + if (post.body.length > 100) "..." else "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Composable
fun ErrorScreen(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp)
) {
Text("Retry")
}
}
}
Adding authentication headers
class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val token = tokenProvider()
val newRequest = if (token != null) {
request.newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
request
}
return chain.proceed(newRequest)
}
}
// Add to OkHttpClient
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor { tokenStorage.getAccessToken() })
.build()
Summary
The full stack:
- Retrofit — Define API as a Kotlin interface with suspend functions
- Repository — Wrap API calls with error handling, return
ApiResult - ViewModel — Manage UI state with
StateFlow, call repository inviewModelScope - Compose — Observe state with
collectAsStateWithLifecycle, render based on state
This pattern works for any API: REST, GraphQL, gRPC. The composable doesn’t know where the data comes from — it just renders state.
Next and final: Migrating from XML Views to Compose.