Advanced Concurrency

combineLatest: Reactive Form Validation

August 8, 2026
5 min read
Featured image for blog post: combineLatest: Reactive Form Validation

combineLatest emits whenever any input changes, using the latest values from all inputs. Perfect for form validation.

Follow along with the code: iOS-Practice on GitHub

The Problem

A registration form needs validation:

  • Email must be valid
  • Password must be 8+ characters
  • Terms must be accepted

The submit button should only enable when ALL are valid.

combineLatest Solution

class RegistrationViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var acceptedTerms = false
    @Published var isFormValid = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        Publishers.CombineLatest3($email, $password, $acceptedTerms)
            .map { email, password, terms in
                self.isValidEmail(email) &&
                password.count >= 8 &&
                terms
            }
            .assign(to: &$isFormValid)
    }

    private func isValidEmail(_ email: String) -> Bool {
        email.contains("@") && email.contains(".")
    }
}

How combineLatest Works

$email:     ----"a"----"ab"----"ab@"----"ab@x.com"---->
$password:  ----""-----"p"-----"pa"-----"password"---->
$terms:     ----false--false---true----true----------->

Combined:   ----(a,"",F)-(ab,p,F)-(ab@,pa,T)-(ab@x.com,password,T)->
isValid:    ----false----false----false------true----------------->

Every time ANY input emits, the combined output fires with the latest from all three.

Real-Time Feedback

Show validation errors as user types:

@Published var emailError: String?
@Published var passwordError: String?

init() {
    $email
        .dropFirst()  // Don't validate empty initial state
        .map { email -> String? in
            if email.isEmpty { return nil }
            if !self.isValidEmail(email) {
                return "Please enter a valid email"
            }
            return nil
        }
        .assign(to: &$emailError)

    $password
        .dropFirst()
        .map { password -> String? in
            if password.isEmpty { return nil }
            if password.count < 8 {
                return "Password must be at least 8 characters"
            }
            return nil
        }
        .assign(to: &$passwordError)
}

CombineLatest Variants

// Two publishers
Publishers.CombineLatest(pub1, pub2)
    .sink { value1, value2 in }

// Three publishers
Publishers.CombineLatest3(pub1, pub2, pub3)
    .sink { value1, value2, value3 in }

// Four publishers
Publishers.CombineLatest4(pub1, pub2, pub3, pub4)
    .sink { v1, v2, v3, v4 in }

For more than 4, nest them:

Publishers.CombineLatest(
    Publishers.CombineLatest(pub1, pub2),
    Publishers.CombineLatest(pub3, pub4)
)
.map { (pair1, pair2) in
    let (v1, v2) = pair1
    let (v3, v4) = pair2
    return (v1, v2, v3, v4)
}

Important: Initial Values

combineLatest waits until ALL publishers have emitted at least once:

let pub1 = CurrentValueSubject<Int, Never>(1)  // Emits immediately
let pub2 = PassthroughSubject<Int, Never>()    // No initial value

Publishers.CombineLatest(pub1, pub2)
    .sink { print($0, $1) }
    // Nothing printed yet!

pub2.send(2)  // Now prints: (1, 2)

Use @Published (which is a CurrentValueSubject) or provide initial values.

SwiftUI Integration

struct RegistrationView: View {
    @StateObject private var viewModel = RegistrationViewModel()

    var body: some View {
        Form {
            TextField("Email", text: $viewModel.email)
            SecureField("Password", text: $viewModel.password)
            Toggle("Accept Terms", isOn: $viewModel.acceptedTerms)

            Button("Register") {
                register()
            }
            .disabled(!viewModel.isFormValid)
        }
    }
}

Interview Tip

combineLatest is the go-to for derived state from multiple sources. When asked about reactive form validation, this is the standard answer. Emphasize that it fires on ANY change, using the LATEST values.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch