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.