DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Forms, Validation & Focus Flow Architecture (Production-Grade)

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?
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

This keeps validation derived, not stored.


⌨️ 4. Focus Flow with FocusState

Model focus explicitly:

enum Field: Hashable {
    case email
    case password
}
Enter fullscreen mode Exit fullscreen mode
@FocusState private var focusedField: Field?
Enter fullscreen mode Exit fullscreen mode

Bind fields:

TextField("Email", text: $state.email)
    .focused($focusedField, equals: .email)

SecureField("Password", text: $state.password)
    .focused($focusedField, equals: .password)
Enter fullscreen mode Exit fullscreen mode

🔄 5. Keyboard Navigation

Handle submit naturally:

.onSubmit {
    switch focusedField {
    case .email:
        focusedField = .password
    default:
        submit()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

🧠 7. First Invalid Field Resolution

var firstInvalidField: Field? {
    if state.emailError != nil { return .email }
    if state.passwordError != nil { return .password }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

No UI tests needed for validation.


❌ Common Anti-Patterns

Avoid:

  • one @State per 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
Enter fullscreen mode Exit fullscreen mode

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)