```html SwiftUI: How to Build GraphQL Client (iOS 17+, 2026)

How to Implement a GraphQL Client in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: URLSession, Codable Updated: May 11, 2026
TL;DR

Encode a GraphQLRequest to JSON, POST it via URLSession, then decode the response through a generic GraphQLResponse<T: Decodable> wrapper. No third-party libraries needed — Swift's type system handles query variables and response mapping.

struct GraphQLRequest: Encodable {
    let query: String
    var variables: [String: String]? = nil
}

struct GraphQLResponse<T: Decodable>: Decodable {
    let data: T?
    let errors: [GraphQLError]?
}

struct GraphQLError: Decodable { let message: String }

func graphQL<T: Decodable>(_ type: T.Type, query: String,
    url: URL) async throws -> T {
    var req = URLRequest(url: url)
    req.httpMethod = "POST"
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.httpBody = try JSONEncoder().encode(GraphQLRequest(query: query))
    let (data, _) = try await URLSession.shared.data(for: req)
    let resp = try JSONDecoder().decode(GraphQLResponse<T>.self, from: data)
    guard let result = resp.data else {
        throw GraphQLClientError.serverError(resp.errors?.first?.message ?? "Unknown")
    }
    return result
}

Full implementation

The full client wraps URLSession in a reusable GraphQLClient actor, keeping network calls off the main thread. An @Observable view model drives SwiftUI state transitions — loading, loaded, and error — without any external dependencies. Query variables are typed as [String: JSONValue] so integers, booleans, and strings all serialize cleanly.

import SwiftUI

// MARK: - JSON value enum for typed variables

enum JSONValue: Encodable {
    case string(String)
    case int(Int)
    case bool(Bool)
    case null

    func encode(to encoder: Encoder) throws {
        var c = encoder.singleValueContainer()
        switch self {
        case .string(let s): try c.encode(s)
        case .int(let i):    try c.encode(i)
        case .bool(let b):   try c.encode(b)
        case .null:          try c.encodeNil()
        }
    }
}

// MARK: - Request / Response types

struct GraphQLRequest: Encodable {
    let query: String
    var variables: [String: JSONValue]? = nil
    var operationName: String? = nil
}

struct GraphQLResponse<T: Decodable>: Decodable {
    let data: T?
    let errors: [GraphQLError]?
}

struct GraphQLError: Decodable, LocalizedError {
    let message: String
    var errorDescription: String? { message }
}

enum GraphQLClientError: LocalizedError {
    case serverError(String)
    case emptyData
    var errorDescription: String? {
        switch self {
        case .serverError(let m): "GraphQL error: \(m)"
        case .emptyData:          "Response contained no data."
        }
    }
}

// MARK: - Client

actor GraphQLClient {
    private let endpoint: URL
    private let session: URLSession
    private var authToken: String?

    init(endpoint: URL, authToken: String? = nil,
         session: URLSession = .shared) {
        self.endpoint = endpoint
        self.authToken = authToken
        self.session = session
    }

    func fetch<T: Decodable>(
        _ type: T.Type,
        query: String,
        variables: [String: JSONValue]? = nil,
        operationName: String? = nil
    ) async throws -> T {
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("application/json",
                         forHTTPHeaderField: "Content-Type")
        if let token = authToken {
            request.setValue("Bearer \(token)",
                             forHTTPHeaderField: "Authorization")
        }
        let body = GraphQLRequest(query: query,
                                  variables: variables,
                                  operationName: operationName)
        request.httpBody = try JSONEncoder().encode(body)

        let (data, response) = try await session.data(for: request)
        if let http = response as? HTTPURLResponse,
           !(200...299).contains(http.statusCode) {
            throw URLError(.badServerResponse)
        }

        let gqlResponse = try JSONDecoder().decode(
            GraphQLResponse<T>.self, from: data)

        if let errors = gqlResponse.errors, !errors.isEmpty {
            throw GraphQLClientError.serverError(
                errors.map(\.message).joined(separator: "; "))
        }
        guard let result = gqlResponse.data else {
            throw GraphQLClientError.emptyData
        }
        return result
    }
}

// MARK: - Sample domain model

struct Country: Decodable, Identifiable {
    let code: String
    let name: String
    let emoji: String
    var id: String { code }
}

struct CountriesData: Decodable {
    let countries: [Country]
}

// MARK: - ViewModel

@Observable
final class CountriesViewModel {
    var countries: [Country] = []
    var isLoading = false
    var errorMessage: String?

    private let client = GraphQLClient(
        endpoint: URL(string: "https://countries.trevorblades.com/graphql")!
    )

    private let query = """
    query GetCountries {
      countries {
        code
        name
        emoji
      }
    }
    """

    func load() async {
        isLoading = true
        errorMessage = nil
        do {
            let result = try await client.fetch(
                CountriesData.self, query: query)
            countries = result.countries
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }
}

// MARK: - View

struct CountriesView: View {
    @State private var vm = CountriesViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if vm.isLoading {
                    ProgressView("Loading…")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                } else if let error = vm.errorMessage {
                    ContentUnavailableView(
                        "Error", systemImage: "wifi.slash",
                        description: Text(error))
                } else {
                    List(vm.countries) { country in
                        Label {
                            VStack(alignment: .leading) {
                                Text(country.name)
                                    .font(.headline)
                                Text(country.code)
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                        } icon: {
                            Text(country.emoji)
                                .font(.title2)
                                .accessibilityHidden(true)
                        }
                        .accessibilityLabel("\(country.name), \(country.code)")
                    }
                }
            }
            .navigationTitle("Countries")
            .task { await vm.load() }
        }
    }
}

#Preview {
    CountriesView()
}

How it works

  1. JSONValue enum — GraphQL variables can be mixed types. The custom Encodable enum solves Swift's limitation of not being able to encode heterogeneous dictionaries natively, mapping each case to a flat JSON primitive.
  2. actor GraphQLClient — Declaring the client as an actor (iOS 17+) protects mutable state like authToken from data races without manual locking. All calls automatically hop off the main actor.
  3. Generic fetch<T> — Passing the expected return type as a parameter (fetch(CountriesData.self, …)) lets the compiler resolve the full GraphQLResponse<T> decode path at compile time, giving type-safe access to every field without casting.
  4. Two-layer error handling — HTTP errors (4xx/5xx) are surfaced via URLError(.badServerResponse) before JSON decoding starts. GraphQL application-level errors (inside "errors": []) are then checked separately, which matches the GraphQL spec correctly.
  5. @Observable ViewModel + .task — The .task modifier ties the async load() call to the view's lifetime, auto-cancelling if the view disappears. SwiftUI re-renders automatically when isLoading, countries, or errorMessage change via the @Observable macro.

Variants

Passing typed query variables

struct CountryDetail: Decodable {
    let country: Country
}

let detailQuery = """
query GetCountry($code: ID!) {
  country(code: $code) {
    code
    name
    emoji
  }
}
"""

// In ViewModel or View:
let detail = try await client.fetch(
    CountryDetail.self,
    query: detailQuery,
    variables: ["code": .string("US")],
    operationName: "GetCountry"
)
print(detail.country.name) // "United States"

Refreshing the auth token at runtime

Because GraphQLClient is an actor, you can safely add a func setAuthToken(_ token: String) method that updates the stored token. Call it from your authentication flow — any in-flight fetch will finish with the old token, and subsequent fetches pick up the new one without a race condition. For OAuth, store the token in Keychain and re-inject it after each silent refresh rather than holding it in memory.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a GraphQL client in SwiftUI for iOS 17+.
Use URLSession and Codable (no Apollo or third-party libs).
Support typed query variables via a JSONValue enum.
Wrap the client in an actor to prevent data races.
Drive UI with an @Observable ViewModel and .task modifier.
Make it accessible (VoiceOver labels on all list rows).
Add a #Preview with realistic sample data using a public GraphQL API.

Drop this prompt into Soarias during the Build phase after scaffolding your data model — Claude Code will wire the client, view model, and SwiftUI view in one pass, leaving you to swap in your actual endpoint URL and schema types.

Related

FAQ

Does this work on iOS 16?

The networking layer (URLSession async/await) works back to iOS 15. However, the @Observable macro and ContentUnavailableView require iOS 17. For iOS 16 support, replace @Observable with ObservableObject + @Published, and swap ContentUnavailableView for a custom error state view. The actor-based client is fully compatible with iOS 15+.

How do I handle GraphQL subscriptions (real-time data)?

URLSession doesn't support WebSocket-based subscriptions out of the box beyond basic URLSessionWebSocketTask. For subscriptions, use URLSessionWebSocketTask with a JSON-over-WebSocket protocol (most GraphQL servers support the graphql-ws sub-protocol), or integrate a lightweight library like Apollo iOS only for the subscription layer while keeping your query/mutation logic in this native client.

What's the UIKit equivalent?

The GraphQLClient actor is UIKit-compatible as-is — it has no SwiftUI dependency. Call fetch from a UIViewController using Task { await vm.load() } in viewDidLoad, then update your table view in a MainActor.run block. The Codable models, request/response types, and error handling are identical between UIKit and SwiftUI projects.

Last reviewed: 2026-05-11 by the Soarias team.

```