PCSalt
YouTube GitHub
Back to Jetpack Compose
Jetpack Compose · 1 min read

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.

  1. Your First Screen
  2. State Management
  3. Navigation
  4. Lists
  5. Theming
  6. Forms
  7. Side Effects
  8. Custom Layouts
  9. API Integration (this post)
  10. 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:

  1. Retrofit — Define API as a Kotlin interface with suspend functions
  2. Repository — Wrap API calls with error handling, return ApiResult
  3. ViewModel — Manage UI state with StateFlow, call repository in viewModelScope
  4. 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.