SwiftUI onChange Called Multiple Times: Causes & Fix
SwiftUI's onChange modifier may trigger multiple times unexpectedly, causing performance issues or incorrect logic execution.
The Problem
Here's an example of code that causes the onChange modifier to be triggered multiple times unnecessarily:
// ❌ Wrong
struct ContentView: View {
@State private var text = ""
var body: some View {
TextField("Enter text", text: $text)
.onChange(of: text) { newValue in
print("Text changed to: $newValue)")
}
}
}
When typing into the TextField, the onChange block may be called multiple times for a single change, leading to unexpected behavior or performance degradation.
Root Cause
The onChange modifier in SwiftUI is designed to observe changes to a specific value and execute a closure when that value changes. However, SwiftUI's state management and view lifecycle can sometimes cause the closure to be triggered multiple times during a single update cycle. This typically occurs when:
- The observed value is updated multiple times synchronously within the same transaction.
- The view body is recomputed multiple times in a short span, such as during animations or rapid state updates.
SwiftUI batches state updates internally, but onChange does not debounce or throttle its execution. Therefore, if the observed value changes more than once during a single frame or transaction, the closure may be invoked multiple times.
The Fix
To prevent multiple invocations of onChange, ensure that the observed value is updated only once per transaction or use a debouncing mechanism. Here's a minimal fix using a conditional check to avoid redundant executions:
// ✅ Fixed
struct ContentView: View {
@State private var text = ""
@State private var previousText = ""
var body: some View {
TextField("Enter text", text: $text)
.onChange(of: text) { newValue in
guard newValue != previousText else { return }
previousText = newValue
print("Text changed to: $newValue)")
}
}
}
This fix ensures that the onChange closure only runs when the value has actually changed from the last processed state.
Alternative Approaches
- Use a Debounced State Update: You can use
DispatchQueueto debounce the changes and only triggeronChangeafter a delay:
.onChange(of: text) { newValue in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
print("Text changed to: $newValue)")
}
}
- Use a Custom Binding: Wrap the state update in a custom
Bindingthat controls when the update is propagated:
var debouncedBinding: Binding<String> {
Binding(
get: { text },
set: { newValue in
if newValue != text {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
text = newValue
}
}
}
)
}
// Use it in the TextField:
TextField("Enter text", text: debouncedBinding)
Affected iOS Versions
This behavior is present in iOS 14 through iOS 16. In iOS 17, Apple improved state handling in some cases, but developers should still account for potential duplicate triggers in complex view hierarchies.