0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kotlin Compose for Web × Ktor Client でGraphQL APIからデータ取得する実装ガイド

0
Last updated at Posted at 2026-03-21

private: true

はじめに

NBAのトレード速報サイトを個人開発しているのだが、フロントエンドに Kotlin Compose for Web、バックエンドに Rust (async-graphql) という構成を採用している。

フロントからバックのGraphQL APIを叩く部分で、Ktor Client を使った実装がうまくハマったので共有する。

📌 この記事で扱うこと:

  • Compose for Web で Ktor Client を使う初期設定
  • GraphQL リクエスト/レスポンスのシリアライズ設計
  • Composable から非同期でデータ取得するパターン
  • 実際にハマったポイントと解決策

環境

項目 バージョン
Kotlin 1.9.22
Compose for Web 1.5.12
Ktor Client 2.3.7
kotlinx-serialization 1.6.2
kotlinx-coroutines 1.7.3

実装

1. build.gradle.kts の依存関係

まず必要な依存を追加する。Compose for Web は Kotlin/JS (IR) ターゲットで動くので、Ktor の ktor-client-js エンジンを使う。

kotlin {
    js(IR) {
        browser {
            commonWebpackConfig {
                cssSupport { enabled.set(true) }
            }
        }
        binaries.executable()
    }

    sourceSets {
        val jsMain by getting {
            dependencies {
                implementation(compose.html.core)
                implementation(compose.runtime)

                // Ktor Client (JS エンジン)
                implementation("io.ktor:ktor-client-core:2.3.7")
                implementation("io.ktor:ktor-client-js:2.3.7")
                implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
                implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")

                // シリアライズ
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
            }
        }
    }
}

ポイントは ktor-client-js を指定すること。ktor-client-cio 等を入れるとJS環境では動かないので注意。

2. GraphQL クライアントの実装

GraphQLのリクエスト/レスポンスは専用のデータクラスで型安全に扱う。

@Serializable
data class GraphQLRequest(
    val query: String,
    val variables: Map<String, String?> = emptyMap()
)

@Serializable
data class GraphQLResponse<T>(
    val data: T? = null,
    val errors: List<GraphQLError>? = null
)

@Serializable
data class GraphQLError(
    val message: String
)

そしてクライアント本体。HttpClient(Js) でブラウザのFetch APIをエンジンとして使う。

class GraphQLClient : TradeNewsApiClient {
    private val client: HttpClient
    private val endpoint: String

    constructor(
        client: HttpClient = createDefaultClient(),
        endpoint: String = ApiConfig.apiEndpoint
    ) {
        this.client = client
        this.endpoint = endpoint
    }

    override suspend fun fetchNewsItems(category: String?): List<NewsItem> {
        return if (category == null) {
            fetchAllTradeNews()
        } else {
            fetchTradeNewsByCategory(category)
        }
    }

    private suspend fun fetchAllTradeNews(): List<NewsItem> {
        val query = """
            query GetAllTradeNews {
                tradeNews {
                    id
                    title
                    description
                    link
                    source
                    publishedAt
                    category
                    titleJa
                    descriptionJa
                    translationStatus
                    translatedAt
                }
            }
        """.trimIndent()

        val request = GraphQLRequest(query = query)
        val response: GraphQLResponse<TradeNewsData> = try {
            client.post(endpoint) {
                contentType(ContentType.Application.Json)
                setBody(request)
            }.body()
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            throw GraphQLClientException(
                "Failed to fetch trade news: ${e.message ?: "unknown error"}", e
            )
        }

        return when {
            !response.errors.isNullOrEmpty() ->
                throw GraphQLClientException(
                    response.errors.joinToString(", ") { it.message }
                )
            response.data != null -> response.data.tradeNews
            else -> throw GraphQLClientException(
                "GraphQL response did not contain trade news data"
            )
        }
    }

    companion object {
        private fun createDefaultClient(): HttpClient {
            return HttpClient(Js) {
                install(ContentNegotiation) {
                    json(Json {
                        prettyPrint = true
                        isLenient = true
                        ignoreUnknownKeys = true
                    })
                }
            }
        }
    }
}

ignoreUnknownKeys = true がかなり重要で、バックエンド側のスキーマが変わっても(フィールドが増えても)フロントが壊れないようにしている。

3. Composable からデータを取得する

Compose for Web では LaunchedEffect を使ってコルーチンを起動し、非同期でAPIを叩く。

@Composable
fun NewsList(
    selectedCategory: String?,
    client: TradeNewsApiClient,
    onFetchStateChanged: (Throwable?) -> Unit = {}
) {
    var newsItems by remember { mutableStateOf<List<NewsItem>>(emptyList()) }
    var isLoading by remember { mutableStateOf(true) }
    var error by remember { mutableStateOf<String?>(null) }
    var retryNonce by remember { mutableStateOf(0) }

    LaunchedEffect(selectedCategory, retryNonce, client) {
        isLoading = true
        error = null
        onFetchStateChanged(null)
        try {
            newsItems = client.fetchNewsItems(selectedCategory)
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            newsItems = emptyList()
            error = buildNewsFetchErrorMessage(e)
            onFetchStateChanged(e)
        } finally {
            if (currentCoroutineContext().isActive) {
                isLoading = false
            }
        }
    }

    // UI描画...
}

ここでのポイント:

  • LaunchedEffect のキーに selectedCategory を入れる → カテゴリが変わったら自動的に再fetch
  • retryNonce パターン → リトライボタン押下で retryNonce += 1 するだけで再実行される
  • CancellationException は再throw → コルーチンのキャンセルを握り潰すと状態がおかしくなる
  • currentCoroutineContext().isActive チェック → キャンセル済みのコルーチンで state を更新しない

4. エンドポイントの動的解決

開発環境と本番環境でAPIのURLが異なる問題は、ApiConfig オブジェクトで解決した。

object ApiConfig {
    val apiEndpoint: String
        get() {
            val hostname = window.location.hostname
            return when {
                hostname == "nba-iso-flow.com" ->
                    "https://nba-iso-flow.com/graphql"
                hostname.contains("cloudfront.net") ->
                    "https://${hostname}/graphql"
                hostname == "localhost" || hostname == "127.0.0.1" ->
                    "/graphql"  // webpack-dev-server のプロキシ経由
                else -> "/graphql"
            }
        }
}

ローカル開発では webpack-dev-server のプロキシを通して /graphql にアクセスし、本番ではドメイン名から自動判別する。環境変数を使わずにホスト名ベースで切り替えるので、ビルド成果物を環境ごとに分ける必要がない。

ハマったポイント

CancellationException を catch して握り潰すとUIがフリーズする

最初はこう書いていた:

// ❌ NG: CancellationException も catch してしまう
try {
    newsItems = client.fetchNewsItems(selectedCategory)
} catch (e: Exception) {
    error = e.message
}

これだと LaunchedEffect が再実行される際のキャンセル時にも error が設定されてしまい、一瞬エラー表示がチラつく。さらに悪いケースでは、コルーチンのキャンセル伝搬が止まってUIが更新されなくなる。

解決策: CancellationException を明示的に再throwする。

// ✅ OK: CancellationException は再throw
try {
    newsItems = client.fetchNewsItems(selectedCategory)
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    error = e.message
}

Kotlinのコルーチンを扱う限り、この CancellationException パターンは全ての try-catch に入れた方がいい。

Ktor Client の JS エンジンと TLS

HttpClient(Js) はブラウザの fetch() APIを内部で使っているため、TLS周りはブラウザに任せられる。一方でテスト環境(Node.js)では挙動が違うことがあり、ktor-client-mock を使ったテストの方が安定した。

ignoreUnknownKeys を忘れるとデシリアライズで死ぬ

バックエンド(Rust側)にフィールドを追加した瞬間、フロントがクラッシュする。Json の設定で ignoreUnknownKeys = true を入れておかないと、スキーマの進化に対応できない。GraphQLのintrospectionで型を同期する仕組みがない分、ここで柔軟性を持たせている。

まとめ

Kotlin Compose for Web × Ktor Client でGraphQL APIを叩く構成は、以下のメリットがある:

  • 型安全: @Serializable でリクエスト/レスポンスを型定義
  • リアクティブ: LaunchedEffect + mutableStateOf でデータ変更に自動追従
  • テスタブル: TradeNewsApiClient インターフェースで差し替え容易

Compose for Web はまだ日本語の情報が少ないが、Androidの Jetpack Compose を触ったことがある人なら違和感なく書ける。バックエンドにRustを置いてGraphQLで繋ぐ構成は、パフォーマンスと型安全性の両方を得られるのでおすすめ。


🏀 NBA Trade Tracker: https://www.nba-iso-flow.com/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?