Overview of the onChange() modifier in SwiftUI
SwiftUI provides the onChange() modifier to run code in response to value changes. It watches a value passed to it as an argument and, since the value must conform to Equatable, uses equality to determine whether the closure should be called. The modifier has gone through some API revisions since it was introduced in iOS 14, and in this post we'll look at the different variants and see where each one applies. We'll also cover how to trigger the action on initial render, the difference between the view and scene modifiers, and running asynchronous code inside the action closure.
# The old APIs
The original onChange(of:perform:) view modifier and onChange(of:perform:) scene modifier were introduced in iOS 14. These versions received the new value as a single parameter in the action closure.
TextField("Search", text: $query)
.onChange(of: query) { newValue in
performSearch(newValue)
}
If we also needed the old value, we had to capture it in the closure's capture list.
struct ContentView: View {
@State private var query = ""
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { [query] newValue in
print("old value: \(query)")
print("new value: \(newValue)")
}
}
}
These variants were deprecated in iOS 17, when new signatures were introduced.
# The modern APIs
The modern onChange(of:initial:_:) view modifier and onChange(of:initial:_:) scene modifier come in two new signatures. We can see both signatures by clicking "Show all declarations" in the documentation.
The first variant of the signature takes a closure with no parameters. We can read the current value directly inside the closure, and it will reflect the new state after the change.
struct ContentView: View {
@State private var query = ""
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) {
performSearch(query)
}
}
...
}
The second variant takes a closure that provides both the old and new value as parameters, which is the option to use when we need both.
TextField("Search", text: $query)
.onChange(of: query) { oldValue, newValue in
print("old value: \(oldValue)")
print("new value: \(newValue)")
}
Note that a capture list in either variant captures the new state, not the old one, which is the opposite of what the same pattern did in the old API.
struct ContentView: View {
@State private var query = ""
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { [query] in
print("new value: \(query)")
}
}
}
# Initial value
By default, onChange() does not fire when the view first appears. It only responds to subsequent changes after the initial render. We can change this behavior with the initial parameter. When it's set to true, the closure runs once immediately with the current value, and then again on every subsequent change.
TextField("Search", text: $query)
.onChange(of: query, initial: true) {
performSearch(query)
}
When using the two-parameter variant, the first call receives the same value for both oldValue and newValue, since there is no previous value to compare against. We can use that to run different logic depending on whether we are in the initial call or a subsequent one.
TextField("Search", text: $query)
.onChange(of: query, initial: true) { oldValue, newValue in
if oldValue == newValue {
setupInitialState(query)
} else {
performSearch(newValue)
}
}
# View modifier vs scene modifier
SwiftUI provides onChange() on both View and Scene. The two behave similarly in structure, but they differ in lifecycle and what values make sense to observe.
The view modifier runs within the lifecycle of the view it is attached to. It fires when the view is active and stops when the view is removed from the hierarchy. This makes it well suited for reacting to local state, bindings, or environment values that are relevant to a specific part of the UI.
The scene modifier runs at the scene level, which means it persists across the full lifecycle of the scene rather than any individual view. This is where we observe values that belong to the app's wider state, such as scenePhase, for example.
@main
struct MyApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .background {
saveAppState()
}
}
}
}
# Main thread considerations
The action closure passed to onChange() may be called on the main actor. This means any long-running work we do inside it can block the UI.
To avoid this, we can call an async function from within the closure using a Task, and make sure the expensive work inside that function runs off the main actor, for example by calling into an async API that doesn't run on the main thread.
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .active {
Task {
await loadData()
}
}
}
As we have seen, onChange() has evolved since iOS 14, with the newer variants giving us more control over how we access values and when the closure runs. Knowing the available options, from the signature we choose to where we apply the modifier, makes it easier to use the API correctly and keep side effects predictable.
If you are looking to build a strong foundation in SwiftUI, my book SwiftUI Fundamentals takes a deep dive into the framework's core principles and APIs to help you understand how it works under the hood and how to use it effectively in your projects. And my new book The SwiftUI Way helps you adopt recommended patterns, avoid common pitfalls, and use SwiftUI's native tools appropriately to work with the framework rather than against it.
For more resources on Swift and SwiftUI, check out my other books and book bundles.



