Forms look simple — until they aren’t.
Once you add:
- multiple fields
- validation rules
- async checks
- keyboard navigation
- error states
- submit/loading states
Most SwiftUI forms turn into:
- tangled state
- broken focus
- duplicated logic
- unreadable views
This post shows how to build production-grade SwiftUI forms with:
- clean validation
- predictable focus flow
- async-safe submission
- testable architecture
🧠 The Core Principle
Forms are state machines, not collections of TextFields.
You should model:
- input
- validation
- focus
- submission
- errors
…explicitly.
📦 1. Define a Form State Model
Start with a single source of truth:
struct LoginFormState {
var email = ""
var password = ""
var isSubmitting = false
var error: String?
}
Avoid scattered @State per field.
🎯 2. Validation as Pure Functions
Validation must be:
- synchronous
- deterministic
- side-effect free
struct LoginValidator {
static func validateEmail(_ email: String) -> String? {
email.contains("@") ? nil : "Invalid email"
}
static func validatePassword(_ password: String) -> String? {
password.count >= 8 ? nil : "Password too short"
}
}
No async. No UI logic.
🧩 3. Field-Level Validation State
Expose validation without mutating the form:
extension LoginFormState {
var emailError: String? {
LoginValidator.validateEmail(email)
}
var passwordError: String? {
LoginValidator.validatePassword(password)
}
var isValid: Bool {
emailError == nil && passwordError == nil
}
}
This keeps validation derived, not stored.
⌨️ 4. Focus Flow with FocusState
Model focus explicitly:
enum Field: Hashable {
case email
case password
}
@FocusState private var focusedField: Field?
Bind fields:
TextField("Email", text: $state.email)
.focused($focusedField, equals: .email)
SecureField("Password", text: $state.password)
.focused($focusedField, equals: .password)
🔄 5. Keyboard Navigation
Handle submit naturally:
.onSubmit {
switch focusedField {
case .email:
focusedField = .password
default:
submit()
}
}
This works across:
- hardware keyboard
- software keyboard
- accessibility input
🚦 6. Submit Pipeline (Async-Safe)
Never submit directly from the view.
func submit() async {
guard state.isValid else {
focusedField = firstInvalidField
return
}
state.isSubmitting = true
defer { state.isSubmitting = false }
do {
try await auth.login(
email: state.email,
password: state.password
)
} catch {
state.error = error.localizedDescription
}
}
🧠 7. First Invalid Field Resolution
var firstInvalidField: Field? {
if state.emailError != nil { return .email }
if state.passwordError != nil { return .password }
return nil
}
This creates predictable focus recovery.
⚠️ 8. Error Presentation Rules
Errors should be:
- field-level when possible
- form-level when necessary
- dismissible
- non-blocking
if let error = state.error {
Text(error)
.foregroundStyle(.red)
}
Avoid modal alerts for validation.
🧪 9. Testing Becomes Trivial
Because logic is pure:
func testInvalidEmail() {
let state = LoginFormState(email: "test", password: "password")
XCTAssertNotNil(state.emailError)
}
No UI tests needed for validation.
❌ Common Anti-Patterns
Avoid:
- one
@Stateper field - validation inside views
- async validation on every keystroke
- auto-focusing randomly
- disabling fields without feedback
- mixing UI + business logic
🧠 Mental Model
Think of a form as:
Input
→ Validation
→ Focus Resolution
→ Submission
→ Error Handling
Each step is explicit, predictable, and testable.
🚀 Final Thoughts
A well-architected SwiftUI form:
- feels effortless
- handles errors gracefully
- respects keyboard flow
- scales to complex cases
- is easy to test
Once you stop treating forms as “just UI” and start treating them as state machines, everything clicks.
Top comments (0)