🛄 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)You’ll 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 orvalidationState
)
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.