🛄 Reactive SwiftUI Forms with SwiftData, Validation & Combine

🛄 Reactive SwiftUI Forms with SwiftData, Validation & Combine

🛄 Reactive SwiftUI Forms with SwiftData, Validation & Combine

Most forms feel like they were slapped together at the gate right before final boarding — rushed, chaotic, and guaranteed to cause turbulence once users start tapping. You tap. You type. You pray. Then you hit “Next” and get slapped with a wall of red errors.

When I built out the passenger check-in flow for a SwiftUI side project, I wanted more than a basic .form stack with some @State toggles. I wanted something that actually reacted—as users typed, exited a field, or made a mistake.

So that’s what we’re building in this post: A passenger info form that:

  • Validates live (sync and async)
  • Saves data into SwiftData as fields are exited
  • Surfaces errors with real-time Combine pipelines
  • And feels slick, responsive, and airline-ready

Oh, and if you look close, the username validator might just give away a nod to a certain 1986 world tour — especially if you try entering a name that belongs in the Snake Pit. Because forms should work — but they can still have a little personality.

🫶 Quick thing before we keep going: If this post saves you a few lines of SwiftUI boilerplate or gets you thinking differently about reactive forms, smashing that 👏 button (up to 50 times!) helps it reach more iOS devs. Seriously — thanks for reading.


🧱 App Setup + SwiftData Model

First, let’s define our Passenger model using the new @Model macro:

import SwiftData

@Model
final class Passenger {
    var fullName: String = ""
    var email: String = ""
    var username: String = ""
    var seat: String = ""
    
    init(fullName: String = "", email: String = "", username: String = "", seat: String = "") {
        self.fullName = fullName
        self.email = email
        self.username = username
        self.seat = seat
    }
}

This is our single source of truth. We’ll use it as a cache — saving form field updates as users exit fields — but you could easily promote this to your canonical source if your app requires it.

To hook it up:

let container = try! ModelContainer(for: Passenger.self)Youll inject this into your SwiftUI views with the `@Environment(\.modelContext)` modifier.

🔄 ViewModel & Validation State Management

Now we wire up the reactive brain — the part of the form that reacts to user input, handles validation, and manages data flow to SwiftData. This is where things start to feel alive.

We’ll be leaning on @Observable to track changes in our model and Combine to debounce and async-check the username field. This separation gives us control over when and how state is updated, and lets SwiftUI do what it does best: render changes reactively.

import Combine
import Observation
import SwiftData

@Observable
final class PassengerFormViewModel {
    var passenger: Passenger
    
    var validationState: [String: ValidationResult] = [:]
    @ObservationIgnored
    var modelContext: ModelContext
    
    private var cancellables = Set<AnyCancellable>()
    
    @ObservationIgnored
    private let usernameSubject = PassthroughSubject<String, Never>()

    init(passenger: Passenger, context: ModelContext) {
        self.passenger = passenger
        self.modelContext = context
        observeUsername()
    }
}

Note: @ObservationIgnored tells SwiftUI's observation system to ignore changes to specific properties when determining if a view needs to update.

Why **cancellables** needs **@ObservationIgnored**:

  • Performance: Without it, every time you .store(in: &cancellables), SwiftUI thinks your view model “changed” and may trigger unnecessary view updates
  • Logical separation: Cancellables are plumbing for managing Combine subscriptions — the UI shouldn’t care about them
  • Clean observation: You only want SwiftUI to react to actual user-facing state changes (like passenger data or validationState)

The rule of thumb: Mark properties with @ObservationIgnored if they're:

  • Implementation details (contexts, cancellables, caches)
  • Infrastructure that doesn’t affect the UI
  • Properties whose changes shouldn’t trigger view refreshes

This keeps your reactive system focused on what actually matters to the user interface.

Let’s set up a Combine pipeline for the username field that:

  • Debounces typing input
  • Runs a mocked async availability check
  • Updates validation state
private func observeUsername() {
    usernameSubject
        .removeDuplicates()
        .debounce(for: .milliseconds(400), scheduler: DispatchQueue.main)
        .flatMap { username in
            self.validationState["username"] = .checking
            return self.checkUsernameAvailability(username)
        }
        .sink { result in
            self.validationState["username"] = result
        }
        .store(in: &cancellables)
}

private func checkUsernameAvailability(_ username: String) -> AnyPublisher<ValidationResult, Never> {
    return Future { promise in
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            let takenNames = ["james", "lars", "kirk", "robert"]
            if takenNames.contains(username.lowercased()) {
                promise(.success(.invalid("Username taken at Gate M72")))
            } else {
                promise(.success(.valid))
            }
        }
    }.eraseToAnyPublisher()
}

func onUsernameChanged(_ newUsername: String) {
    usernameSubject.send(newUsername)
}

🧱 Reusable ValidatedTextField

Let’s wrap up validation, styling, and labels into a single reusable view:

This is more than just a convenience. By building a composable ValidatedTextField, you get:

  • Consistent error handling across fields
  • Cleaner view code with less duplication
  • A clear separation between validation logic and UI rendering

This pattern also makes it easier to tweak the look and feel later — maybe to add icons, animation, or adapt for accessibility — without rewriting logic everywhere else.

struct ValidatedTextField: View {
    let title: String
    @Binding var text: String
    let result: ValidationResult?
    let onSubmit: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            TextField(title, text: $text)
                .textFieldStyle(.roundedBorder)
                .modifier(ValidationStyle(result: result))
                .onSubmit(onSubmit)
                .onChange(of: text) { oldValue, newValue in
                    onChange?(newValue)
            if case .invalid(let message) = result {
                Text(message)
                    .font(.caption)
                    .foregroundStyle(.red)
            } else if result == .checking {
                ProgressView().scaleEffect(0.5)
            }
        }
    }
}

✅ Validation Layer

We’re using both a ValidationResult enum to represent validation states and a FormValidator utility to run the rules. This split keeps things flexible:

  • The enum handles status: .valid, .invalid(String), and .checking
  • The validator does the logic and returns a result

Together, they help keep UI code clean and error messages consistent.

Here’s the enum we’ll use throughout:

enum ValidationResult: Equatable {
    case valid
    case invalid(String)
    case checking
}

struct FormValidator {
    static func validateEmail(_ email: String) -> ValidationResult {
        guard email.contains("@") else {
            return .invalid("Email must contain @")
        }
        return .valid
    }

    static func validateRequired(_ value: String, fieldName: String) -> ValidationResult {
        value.trimmingCharacters(in: .whitespaces).isEmpty ? .invalid("\(fieldName) is required") : .valid
    }
}Then in your ViewModel:

func validateEmail() {
    validationState["email"] = FormValidator.validateEmail(passenger.email)
}

func validateName() {
    validationState["fullName"] = FormValidator.validateRequired(passenger.fullName, fieldName: "Full Name")
}

This makes things clean, easy to extend, and avoids repeating error strings all over your code.


🎨 Custom ViewModifiers for Error Styling

Why use a custom ViewModifier instead of sprinkling .border(.red) everywhere?

Because it makes your styling logic:

  • Reusable across all fields
  • Easier to test and maintain
  • Swappable for theming (e.g. accessibility contrast or dark mode adjustments)

It also keeps your view code focused on layout and flow — not cluttered with UI conditionals.

You’re essentially turning your error styling into a plug-and-play component. That’s gold when working on larger forms, or when design tweaks hit late in the sprint.

struct ValidationStyle: ViewModifier {
  let result: ValidationResult?
  func body(content: Content) -> some View {
    content
      .overlay(
          RoundedRectangle(cornerRadius: 6)
           .stroke(result == .valid ? Color.green : Color.red, lineWidth: 1)
      )
      .animation(.easeInOut, value: result)
  }
}

💾 Validate and Save with SwiftData

We originally used .onSubmit to save data when the user hit the return key. But in practice — especially on mobile — users often skip that step. So we switched to .onChange, which lets us catch updates the moment a field changes.

Now, every field change is validated and saved on the fly. It’s more natural, and it makes SwiftData behave more like local state — which is exactly what you want for a reactive form experience.

What This Provides

  • Real-time persistence: Every keystroke is automatically saved
  • No data loss: User can navigate away or app can crash without losing input
  • Modern UX: Behaves like most iOS apps (Notes, Messages, etc.)
  • Seamless validation: Validation and saving happen together

Performance Notes

SwiftData is designed to handle frequent saves efficiently, so this shouldn’t cause performance issues. The framework batches and optimizes these operations automatically.

Now your reactive forms will provide immediate validation feedback AND auto-save functionality — giving users the best of both worlds!

ValidatedTextField(
    title: "Email",
    text: $viewModel.passenger.email,
    result: viewModel.validationState["email"],
    onSubmit: {
      try? viewModel.modelContext.save() 
    },
    onChange: { _ in
        viewModel.validateEmail()
        try? viewModel.modelContext.save()
    }
)

Use .onSubmit or .onChange(of:) depending on the UX—like whether you want to save the data when the user hits return, or when they simply tap away from the field.


🎛️ Final Polish: UX Touches

  • @FocusState to manage keyboard flow
  • Animations on validation results
  • Padding + spacing tuned for mobile friendly targets

🎸 Closing Notes

Reactive SwiftUI forms aren’t hard — but they’re also not “just bind a field and pray.” You wouldn’t walk up to the gate without a boarding pass — why ship a form that doesn’t validate, persist, or guide the user along the way? If you want something smooth, persistent, and user-friendly, it takes a ViewModel that does more than store strings.

SwiftData gives us a clean save target. Combine handles the async. SwiftUI ties it all together with elegant reactivity.

And if your username happens to be Lars, maybe try seat 1A instead.


🎯 Bonus: More Real-World iOS Survival Stories

If you’re hungry for more tips, tricks, and a few battle-tested stories from the trenches of native mobile development, swing by my collection of articles: @wesleymatlock

These posts are packed with real-world solutions, some laughs, and the kind of knowledge that’s saved me from a few late-night debugging sessions. Let’s keep building apps that rock — and if you’ve got questions or stories of your own, drop me a line. I’d love to hear from you.