Swift and SwiftUI tutorials for Swift Developers

Picker onChange in SwiftUI

Any iOS Developer who has made the leap from UIKit to SwiftUI has encountered a fundamental paradigm shift: moving from imperative to declarative programming. In the old UIKit world, we used delegates (UITextFieldDelegate, UIPickerViewDelegate) or the Target-Action pattern to know when a user interacted with a control.

In modern Swift programming, views are a function of their state. But what happens when we need to execute specific logic exactly at the moment that state changes? What if we want to save data to disk, play a sound, or make an API call when the user selects a new option?

This is where one of the most crucial modifiers in your Xcode arsenal comes into play: the onChange modifier.

In this SwiftUI tutorial, we will explore in depth what it is, how it has evolved in the latest versions of Swift, and how to implement it correctly, with a special focus on highly demanded use cases like Picker onChange SwiftUI, adapting our code for iOS, macOS, and watchOS.


What is the onChange modifier in SwiftUI?

In simple terms, .onChange is a view modifier that listens for changes in a specific value (usually a @State, @Binding, or a property of an @Observable object) and executes a block of code (a closure) every time that value updates.

The common beginner mistake: @State and didSet

Many developers getting started with Swift programming and SwiftUI try to react to state changes using the didSet property observer inside a @State variable:

// ❌ ANTI-PATTERN: This doesn't work well in SwiftUI
@State private var searchText = "" {
    didSet {
        print("The text changed to: \(searchText)")
    }
}

Why is this a bad idea? Because Property Wrappers like @State destroy and recreate the view internally to manage its lifecycle. didSet often doesn’t fire when you expect it to, or it behaves inconsistently.

The correct, native, and safe way Apple provides us in Xcode is to use the .onChange() modifier.


API Evolution: From iOS 14 to iOS 17+

As a good iOS Developer, you should know that SwiftUI evolves rapidly. The onChange modifier received a massive update in iOS 17, macOS 14, and watchOS 10. It is vital to know both versions to maintain legacy code and write modern code.

The old syntax (iOS 14 – iOS 16)

The classic version took the value to observe and returned only the new value to you.

.onChange(of: searchText) { newValue in
    print("The new value is \(newValue)")
}

The modern syntax (iOS 17+ / macOS 14+ / watchOS 10+)

Apple improved this Swift API to give us more context. Now, the closure provides both the old value and the new value. Additionally, they introduced an initial parameter to decide if the action should be executed when the view is drawn for the first time.

.onChange(of: searchText, initial: true) { oldValue, newValue in
    print("Changed from \(oldValue) to \(newValue)")
}

Throughout this tutorial, we will use the modern syntax recommended for the latest versions of Xcode.


Use Case 1: The Classic TextField on iOS

To illustrate the basics, let’s create a simple registration screen on iOS. We want to validate a username in real-time as the user types.

<pre class="wp-block-syntaxhighlighter-code">import SwiftUI
struct UserRegistrationView: View {
    @State private var username = ""
    @State private var errorMessage = ""
    
    var body: some View {
        NavigationStack {
            Form {
                Section(header: Text("Profile Data")) {
                    TextField("Username", text: $username)
                        .autocapitalization(.none)
                        // We implement onChange to validate in real-time
                        .onChange(of: username) { old, new in
                            validateUsername(new)
                        }
                    
                    if !errorMessage.isEmpty {
                        Text(errorMessage)
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                }
            }
            .navigationTitle("Registration")
        }
    }
    
    // Business logic function separated from the view
    private func validateUsername(_ name: String) {
        if name.count < 4 {
            errorMessage = "The username must have at least 4 characters."
        } else if name.contains(" ") {
            errorMessage = "The username cannot contain spaces."
        } else {
            errorMessage = "" // Valid
        }
    }
}</pre>

In this example, every time the user presses a key, the username state updates. The .onChange modifier detects this change and calls our validation function, updating the interface immediately in a reactive manner.


Use Case 2: Mastering Picker onChange SwiftUI

One of the most common searches among the developer community is how to handle a Picker onChange SwiftUI. Unlike a TextField where the user types freely, a Picker represents a discrete selection from a set of predefined options.

Imagine you are developing an e-commerce application and the user must select a shipping method. When changing the shipping method in the Picker, we need to recalculate the total price immediately.

Let’s see how to implement this elegantly using an Enum (one of the most powerful features of Swift programming).

import SwiftUI

// 1. We define our strongly typed options
enum ShippingMethod: String, CaseIterable, Identifiable {
    case standard = "Standard (5-7 days)"
    case express = "Express (24-48 hours)"
    case sameDay = "Same Day"
    
    var id: Self { self }
    
    var price: Double {
        switch self {
        case .standard: return 4.99
        case .express: return 12.99
        case .sameDay: return 24.99
        }
    }
}

struct CheckoutView: View {
    @State private var selectedShipping: ShippingMethod = .standard
    @State private var subtotal: Double = 100.00
    @State private var finalTotal: Double = 104.99
    
    var body: some View {
        NavigationStack {
            List {
                Section("Order Summary") {
                    Text("Subtotal: $\(subtotal, specifier: "%.2f")")
                }
                
                Section("Shipping Options") {
                    // 2. We create the Picker
                    Picker("Shipping method", selection: $selectedShipping) {
                        ForEach(ShippingMethod.allCases) { method in
                            Text(method.rawValue).tag(method)
                        }
                    }
                    .pickerStyle(.navigationLink)
                    // 3. The magic of Picker onChange SwiftUI
                    .onChange(of: selectedShipping) { oldMethod, newMethod in
                        print("User changed from \(oldMethod.rawValue) to \(newMethod.rawValue)")
                        recalculateTotal(with: newMethod)
                    }
                }
                
                Section("Total to Pay") {
                    Text("$\(finalTotal, specifier: "%.2f")")
                        .font(.title)
                        .bold()
                }
            }
            .navigationTitle("Checkout")
        }
    }
    
    private func recalculateTotal(with method: ShippingMethod) {
        // In a real case, we might have complex logic here
        finalTotal = subtotal + method.price
    }
}

Why is this so powerful?

As an iOS Developer, implementing this in UIKit required conforming to UIPickerViewDelegate, creating an array of strings for the rows, reading the selected row index, mapping that index back to your data model, and then forcing an update of the price label.

In SwiftUI, by combining an Enum with @State and .onChange, the data flow is unidirectional, predictable, and requires a fraction of the code in Xcode.


Taking onChange to macOS

The beauty of modern Swift programming is its cross-platform nature. Suppose you are creating a Mac application (macOS). On the desktop, interactions are usually different; we use a mouse, windows, and sidebars.

Let’s use onChange to react to the selection of an item in a typical macOS Sidebar, and we’ll also see how to use the initial parameter introduced in macOS 14.

import SwiftUI

struct MacOSDashboardView: View {
    let categories = ["General", "Accounts", "Privacy", "Advanced"]
    @State private var selectedCategory: String? = "General"
    @State private var loadedData: String = "Waiting for data..."
    
    var body: some View {
        NavigationSplitView {
            List(categories, id: \.self, selection: $selectedCategory) { category in
                Text(category)
            }
            .navigationTitle("Settings")
            // We react to the selection change in the Sidebar
            .onChange(of: selectedCategory, initial: true) { old, new in
                if let activeCategory = new {
                    loadDataForMac(category: activeCategory)
                }
            }
        } detail: {
            VStack(spacing: 20) {
                Text("Viewing: \(selectedCategory ?? "Nothing")")
                    .font(.largeTitle)
                
                Text(loadedData)
                    .foregroundColor(.secondary)
            }
            .padding()
        }
    }
    
    private func loadDataForMac(category: String) {
        // We simulate a data load
        loadedData = "Parameters loaded for section: \(category)."
    }
}

The importance of the initial: true parameter: If you compile this in Xcode, you will notice that when opening the app, loadDataForMac is executed immediately with “General”. If we didn’t use initial: true, the detail panel would show “Waiting for data…” until the user clicked on another option. This saves having to duplicate the function call in an .onAppear modifier.


Physical Interactions on watchOS

On the Apple Watch, users don’t have much space to navigate through complex lists. A common interaction is using the Digital Crown via a Slider. Let’s see how an iOS Developer can adapt their knowledge to react to slider changes on watchOS.

<pre class="wp-block-syntaxhighlighter-code">import SwiftUI
struct WatchOSControlView: View {
    @State private var volume: Double = 50.0
    @State private var volumeIcon: String = "speaker.wave.2.fill"
    
    var body: some View {
        VStack {
            Image(systemName: volumeIcon)
                .font(.system(size: 40))
                .foregroundColor(.blue)
                .padding(.bottom)
            
            // Slider that can be controlled with the screen or Digital Crown
            Slider(value: $volume, in: 0...100, step: 10)
                .tint(.blue)
                .onChange(of: volume) { _, newVolume in
                    updateIcon(for: newVolume)
                    triggerWatchHaptics()
                }
            
            Text("\(Int(volume))%")
        }
        .padding()
    }
    
    private func updateIcon(for level: Double) {
        switch level {
        case 0:
            volumeIcon = "speaker.slash.fill"
        case 1..<33:
            volumeIcon = "speaker.wave.1.fill"
        case 33..<66:
            volumeIcon = "speaker.wave.2.fill"
        default:
            volumeIcon = "speaker.wave.3.fill"
        }
    }
    
    private func triggerWatchHaptics() {
        // In a real watchOS app we would use WKInterfaceDevice
        print("Haptic vibration executed when changing volume")
    }
}</pre>

Using onChange here is crucial because it allows us to trigger side effects (like the watch’s haptic feedback) that don’t purely affect drawing the interface, but rather the physical experience of the device.


Best Practices and Anti-patterns with onChange

To be a Senior iOS Developer, knowing the tool exists isn’t enough; you must know when not to use it or how to avoid performance issues.

1. Beware of Infinite Loops

The most dangerous mistake when using onChange in SwiftUI is modifying the same state you are observing inside the closure block without a solid exit condition.

// ⚠️ DANGER: Possible infinite loop
@State private var amount = 0

var body: some View {
    Button("Add") { amount += 1 }
        .onChange(of: amount) { _, newAmount in
            // If you unconditionally modify the observed state here,
            // onChange will call itself infinitely until it crashes.
            // amount = newAmount + 1 // THIS WILL BREAK THE APP
        }
}

2. Asynchronous Tasks (Async/Await) inside onChange

If you need to call an asynchronous function inside your onChange (for example, downloading something from the internet using the new concurrency system in Swift programming), you cannot do it directly, since the onChange closure is synchronous.

You have two options:

Option A: Use an internal Task (Good)

.onChange(of: searchQuery) { _, newText in
    Task {
        await performNetworkSearch(query: newText)
    }
}

Option B: Use .task(id:) (Better for network calls)

SwiftUI offers a specific modifier called .task(id:) that acts as an asynchronous onChange. If the id (the value it observes) changes before the previous network task finishes, SwiftUI will automatically cancel the previous task and start the new one. This is pure gold for search bars!

TextField("Search", text: $searchText)
    // Executes on start and every time searchText changes.
    // Also natively supports await and cancels obsolete requests.
    .task(id: searchText) {
        await loadAPIResults(query: searchText)
    }

3. Keep the closure lightweight

The onChange modifier executes on the Main Thread. If you put intensive mathematical calculations or heavy blocking logic inside this closure, your UI will freeze (stuttering). Always delegate heavy lifting to external functions that run on background threads (using Task or DispatchQueue.global).


Summary Table: Comparison of Reactive Methods

For a quick reference in your next Xcode project, here is when to use which modifier:

Modifier / Technique When to use it? Native Async Support
didSet in @State Practically NEVER in SwiftUI views. Use only in pure Observable classes (ViewModels). No
.onChange(of:) To react to UI changes (e.g., a Picker), execute side effects, quick validations, or analytics. No (Requires wrapping in Task)
.task(id:) To make network calls (REST APIs) or load data from databases when a UI state changes. Yes (Automatically cancels previous tasks)

Conclusion

The onChange modifier is an absolute pillar in modern Swift programming and the SwiftUI framework. It allows you to bridge the declarative nature of the interface (how things look) and the imperative nature of business logic (what should happen when the user does something).

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

ListStyle in SwiftUI

Next Article

keyboardType in SwiftUI

Related Posts