How to Implement a GraphQL Client in SwiftUI
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
-
JSONValue enum — GraphQL variables can be mixed types. The custom
Encodableenum solves Swift's limitation of not being able to encode heterogeneous dictionaries natively, mapping each case to a flat JSON primitive. -
actor GraphQLClient — Declaring the client as an
actor(iOS 17+) protects mutable state likeauthTokenfrom data races without manual locking. All calls automatically hop off the main actor. -
Generic fetch<T> — Passing the expected return type as a parameter
(
fetch(CountriesData.self, …)) lets the compiler resolve the fullGraphQLResponse<T>decode path at compile time, giving type-safe access to every field without casting. -
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. -
@Observable ViewModel + .task — The
.taskmodifier ties the asyncload()call to the view's lifetime, auto-cancelling if the view disappears. SwiftUI re-renders automatically whenisLoading,countries, orerrorMessagechange via the@Observablemacro.
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
-
Forgetting GraphQL always returns HTTP 200. A query that fails at the
resolver level still returns a 200 response — the error is inside
"errors": [], not in the status code. Always checkgqlResponse.errorsafter a successful HTTP response or you'll silently returnnildata. -
Encoding
[String: Any]crashes.JSONEncodercan't encodeAny. Either use theJSONValueenum shown above or encode variables viaJSONSerializationseparately and stitch the raw bytes — the enum approach is far more maintainable. -
Camel-case mismatch. GraphQL schemas often use camelCase field names that
match Swift conventions, but some servers use snake_case. Set
decoder.keyDecodingStrategy = .convertFromSnakeCaseon yourJSONDecoderinstance when the server returnscountry_codeinstead ofcountryCodeto avoid manualCodingKeysboilerplate. -
Missing VoiceOver on emoji flags. Country emoji (🇺🇸) have no automatic
VoiceOver description in all locales. Always pair them with an
.accessibilityLabelor.accessibilityHidden(true)plus a descriptive sibling label, as shown in theListrow above.
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.