Stack logo
Sync up on the latest from Convex.

Introducing Convex for Swift

Convex for Swift illustration

On the heels of last month’s release of Convex for Android, I’m excited to announce Convex Swift, which unlocks the ability to build iOS and MacOS clients for your Convex app.

Like with the Android library, my goal was to create an API that’s familiar to Swift devs and Convex devs alike.

For those familiar with developing for Apple platforms, the library makes use of the Combine framework which powers parts of SwiftUI and Swift’s async/await functionality. For those already familiar with Convex, you get all the goodness of queries, mutations and actions and the ability to use your backend data and logic to reach an even wider audience.

Getting started

Like our other client libraries, the Swift library is designed for you to build user interfaces that are a function of your Convex application state. The iOS Swift quickstart shows the basic steps requires to get a simple SwiftUI + Convex app off the ground.

What does it look like to build a more fully-functional application though? Read on to see how we might structure a SwiftUI version of the chat app from the official Convex tour.

Say hello to ConvexClient

At the heart of Convex Swift is the ConvexClient. It wraps the official convex-rs Rust client (which handles the connection and communication with the backend) in a developer friendly Swift API.

Your application should keep a single reference to a ConvexClient and use it whenever it needs to communicate with the backend. To create a client, simply pass it the Convex deployment URL to connect to.

let convex = ConvexClient(deploymentUrl: "$YOUR_DEPLOYMENT_URL")

ConvexClient provides methods like mutation to trigger updates to your data based on passed in arguments and subscribe to reactively receive results from a Convex query.

Passing messages with Swift structs

It’s idiomatic to represent structured data as a struct in Swift. Not surprisingly they’re a great fit for the chat messages our app will send and receive and they work well with the ConvexClient too. Here’s a short struct for the chat messages:

struct ChatMessage: Decodable {
  let author: String
  let body: String
}

The struct conforms to the Decodable protocol so it can be marshaled across the FFI boundary between the Kotlin and Rust layers of the library. The author and body properties allow the ChatMessage to match the “shape” of the data stored in the backend messages table.

Querying Convex in a ViewModel

Now that we have our ConvexClient and a struct to represent the data, we need to make the chat data available to our future UI. To do that we’ll create a ViewModel class that conforms to SwiftUI’s ObservableObject protocol. It will house the business logic required to implement a simple chat client: sending and receiving messages.

💡Depending on your goals and/or app scale, you might consider pushing your use of ConvexClient further down and abstracting it from your ViewModels. It seems fairly common to have fewer layers in SwiftUI applications though, which is what we’re presenting in this guide. If you want to see an example of more layering, check out this (third party) Github repo.

SwiftUI offers a couple of options for creating ViewModels: the newer @Observable macro and the ObservableObject protocol. We’re going to go with the tried and true ObservableObject due to some current quirks with @Observable.

First of all, let’s get data into our app by subscribing to a Convex query. We’ll use the @Published wrapper to present the subscription to the incoming chat data as a plain array that will get updated over time.

class ChatViewModel: ObservableObject {
  @Published var incoming: [ChatMessage] = []

  init() {
    convex.subscribe(to: "messages:list")
      .replaceError(with: [])
      .receive(on: DispatchQueue.main)
      .assign(to: &$incoming)
  }
}

That sets up a query subscription that will assign the latest results to the incoming value whenever the backend data changes.

A reactive list of chat messages

With that bit of code in place we’re ready to surface ChatMessage data in a UI! We’ll provide our ChatViewModel and use a SwiftUI List to show the messages.

struct ChatView: View {
  @StateObject var viewModel: ChatViewModel

  var body: some View {
    return VStack {
      List {
        ForEach(viewModel.incoming) { message in
          VStack(alignment: .leading) {
            Text(message.body)
            Text(message.author).font(.system(size: 12, weight: .light, design: .default))
          }
        }
      }
    }.padding()
  }
}

If you simply do something like that, attempting to iterate over ChatMessage values, the compiler is going to complain at you with a message like:

Referencing initializer 'init(_:content:)' on 'ForEach' requires that 'ChatMessage' conform to 'Identifiable’

That’s because SwiftUI needs to be able to identify each item in order to know if it was already present in the list that it has already rendered. We can easily conform Convex data to Identifiable by plumbing through the builtin _id field. We’ll need to tweak our ChatMessage to do that.

First we add Identifiable to the protocols that it conforms to, and then we’ll add the required id field and use a CodingKeys enum to map Convex’s document _id to it.

struct ChatMessage: Identifiable, Decodable {
  let id: String
  let author: String
  let body: String

  enum CodingKeys: String, CodingKey {
    case id = "_id"
    case author
    case body
  }
}

With that code in place, our app can now render chat messages received from Convex!

Screenshot showing chat messages on the screen

Composing and sending messages

A chat app isn’t a chat app without the ability to … chat! So let’s add the ability to send messages.

Back in our ChatViewModel, we’ll add a new @Published property to hold the outgoing message that a user can compose and send. We’ll also add a method that uses a Convex mutation to send the message and clear the outgoing value so another message can be composed

class ChatViewModel: ObservableObject {
  @Published var incoming: [ChatMessage] = []
  @Published var outgoing: String = ""
  
  // Skipping the existing init() method ...

  func sendOutgoing() {
    Task {
      @MainActor in
      try await client.mutation(
        "messages:send", with: ["author": "iOS Demo User", "body": outgoing])
      outgoing = ""
    }
  }
}

In the UI, we’ll add a TextField to connect to the outgoing message property and a Button to connect to the sendOutgoing method.

struct ChatView: View {
  @StateObject var viewModel: ChatViewModel

  var body: some View {
    return VStack {
	    // Skipping the existing incoming messages List ...

      HStack {
        TextField("", text: $viewModel.outgoing)
          .border(.secondary).font(.title2)
        Button(action: viewModel.sendOutgoing) {
          Text("Send").font(.title2)
        }
      }.padding()
    }.padding()
  }
}

The HStack places the TextField and Button side by side. That’s all we need to create a simple UI for composing and sending a new message.

Screenshot showing the chat composer UI widget

Keeping up with the conversation

If you’ve following along this far, and have managed to send and receive enough messages, you might have noticed that there’s a problem with our UI. When enough messages are in the query results, they overflow the bottom of the list and new messages aren’t immediately visible. You have to manually scroll to see them!

We can solve that problem with a bit of SwiftUI.

We’ll wrap our List in a ScrollViewReader that will allow us to scroll the list when the data changes. ScrollViewReader lets us attach code to an onChange handler that we can supply with the incoming list of messages. The handler has access to the old and new state of the data and we simply scroll to the element tagged with the last id. To do that though, we need to tag the VStack that we create with the message author and body with the id.

    ScrollViewReader { scrollView in
      List {
        ForEach(incoming) { message in
          VStack(alignment: .leading) {
            Text(message.body)
            Text(message.author).font(.system(size: 12, weight: .light, design: .default))
          }.id(message.id)
        }
      }.onChange(of: incoming, initial: true) { oldMessages, newMessages in
        withAnimation {
          scrollView.scrollTo(newMessages.last?.id)
        }
      }
    }

Now as new messages are sent and received, the UI will scroll them into view.

When things go wrong

We’re almost at the end of this walkthrough, but I want to turn our attention back to something that we defined near the start. This is what our Convex query subscription code looks like:

    convex.subscribe(to: "messages:list")
      .replaceError(with: [])
      .receive(on: DispatchQueue.main)
      .assign(to: &$incoming)

Notice that we paper over errors received from the query by replacing them with an empty list. That doesn’t allow us to handle them properly (an empty list could mean there are no messages in the chat yet - that’s not an error).

To improve on that, we’ll turn to Swift’s Result enum. It lets us capture potential success and failure state in one type that we can inspect in the UI and use for proper error handling.

First we’ll change the ChatViewModel and subscription code to publish Result<[ChatMessage], ClientError> instead of a plain [ChatMessage].

// Skipping the existing outgoing message code below ...
class ChatViewModel: ObservableObject {
  @Published var incoming: Result<[ChatMessage], ClientError> = Result.success([])

  init() {
    convex.subscribe(to: "messages:list")
      .map(Result.success)
      .catch {error in Just(Result.failure(error))}
      .receive(on: DispatchQueue.main)
      .assign(to: &$incoming)
  }
}

The ClientError type encapsulates the types of errors you can receive from Convex. Now data from the query gets mapped to Result.success and errors are caught and a Result.failure is published.

The last step is updating the UI to deal with the Result type and not a plain list.

First we’ll extract the list of ChatMessages into its own MessageList view. It will keep operating on a simple array of [ChatMessage].

struct MessageList: View {
  var incoming: [ChatMessage]
  
  var body: some View {
    ScrollViewReader { scrollView in
      List {
        ForEach(incoming) { message in
          VStack(alignment: .leading) {
            Text(message.body)
            Text(message.author).font(.system(size: 12, weight: .light, design: .default))
          }.id(message.id)
        }
      }
      .onChange(of: incoming, initial: true) { oldMessages, newMessages in
        withAnimation {
          scrollView.scrollTo(newMessages.last?.id)
        }
      }
    }
  }
}

Then we’ll update the ChatView to show either the MessageList or an error message depending on the Result.

struct ChatView: View {
  @StateObject var viewModel: ChatViewModel
  
  var body: some View {
    return VStack {
      switch viewModel.incoming {
      case .success(let messages):
        MessageList(incoming: messages)
      case .failure(_):
        Spacer()
        HStack {
          Text("Error loading messages").font(.title2)
          Image(systemName: "exclamationmark.triangle.fill").resizable().frame(
            width: 24.0, height: 24.0
          ).foregroundStyle(.yellow)
        }
        Spacer()
      }

      HStack {
        TextField("", text: $viewModel.outgoing)
          .border(.secondary).font(.title2)
        Button(action: viewModel.sendOutgoing) {
          Text("Send").font(.title2)
        }
      }.padding()
    }.padding()
  }
}

If you purposely trigger an error (like calling the wrong query name, messages:listfoo) you’ll now see an error display rendered instead of the message list.

Screenshot showing a loading error

Where to next?

The walkthrough above left out some details for the sake of brevity. It’s minimally styled, doesn’t support many features expected in chat applications and we didn’t even build in user authentication. But hopefully it gave you a taste for what it’s like to build a Swift application using Convex.

If you’re interested in building out a compelling UI using SwiftUI, take a look at Apple’s SwiftUI App Dev Tutorials.

If you want more details on how to use Convex with Swift, check out the official docs and the source code for a multi-screen example app with auth support called Workout Tracker.

We’re excited to see what you build with Convex Swift - let us know if you hit any bugs or have a feature request by filing an issue in the convex-swift Github repo.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started