How to Avoid Double Updates When Filtering SwiftUI TextField Input


Greetings, traveler!

SwiftUI makes it easy to react to text input.

There are two common approaches to handle formatted text input:

  • Use TextField format option
  • Format the string inside onChange and then write the processed value back into the same binding

However, using the first option won’t provide you with live text formatting, leaving the representation unchanged unless you submit it. If it is the desired behaviour, you definitely can stick to it.

@State private var myDouble: Double = 0.673 // The value will only change after formatting to a valid one (if possible).

var body: some View {
    VStack {
        TextField( 
            "Double",
            value: $myDouble,
            format: .number
        ) // The TextField will show anything entered, unless the user ends editing.
    }
}

The second option works in most of the cases, it is quick to implement, and it is often enough.

Sometimes, the processing step matters more than the UI. You may want the rest of your app to observe the text as “already cleaned”: trimmed, limited by length, normalized by case, stripped of non-digits, and so on. In those cases, the usual onChange trimming pattern can introduce a subtle side effect.

The side effect: observers see two values

When you modify the same binding you are observing, the outside world can observe a “raw” value first, then a “corrected” value right after it. If your view model has logic tied to didSet, your app can temporarily operate on an invalid string.

Here is a minimal reproduction:

import SwiftUI

@Observable
final class ViewModel {
    var name = "" {
        didSet {
            print("VM received:", name)
        }
    }
}

struct ContentView: View {
    @State private var vm = ViewModel()
    private let limit = 4

    var body: some View {
        TextField("Name", text: $vm.name)
            .onChange(of: vm.name) { _, newValue in
                // Traditional approach: fix the value after it already changed
                if newValue.count > limit {
                    vm.name = String(newValue.prefix(limit))
                }
            }
    }
}

If you type a fifth character, the view model briefly receives the 5-character string, then it receives the trimmed 4-character string. The UI ends up correct, yet any observer sees a short sequence of “incorrect → corrected”.

This does not always break anything. It becomes noisy when your logic depends on receiving only valid input.

For example, with a 4-character limit and the user typing Flower, the output looks like this:

VM: F
VM: Fl
VM: Flo
VM: Flow
VM: Flowe
VM: Flow

A cleaner contract: buffer the input inside the TextField

To keep the rest of the app clean, you can separate two things:

  • an internal buffer that is bound to the TextField
  • an external binding that only receives the processed value

The TextField works with a local @State value. Every time it changes, you sanitize it and forward only the sanitized value to the outside binding. External updates (reset, prefill, restoring state) are also synchronized back into the local buffer.

Here is the component:

import SwiftUI

struct CustomTextField: View {
    @Binding var text: String
    let maxLength: Int

    @State private var internalText: String = ""

    var body: some View {
        TextField("placeholder", text: $internalText)
            .onAppear {
                sync(from: text)
            }

            // User input path: internal -> filtered -> external
            .onChange(of: internalText) { _, newValue in
                sync(from: text)
            }

            // External updates path: external -> filtered -> internal
            .onChange(of: text) { _, newValue in
                sync(from: newValue)
            }
    }

    private func clamp(_ value: String) -> String {
        String(value.prefix(maxLength))
    }

    private func sync(from external: String) {
        let filtered = clamp(external)

        if internalText != filtered {
            internalText = filtered
        }
        if text != filtered {
            text = filtered
        }
    }
}

And usage with a view model:

import SwiftUI

@Observable
final class ViewModel {
    var name = "" {
        didSet {
            print("VM received:", name)
        }
    }
}

struct ContentView: View {
    @State private var vm = ViewModel()

    var body: some View {
        CustomTextField(text: $vm.name, maxLength: 4)
    }
}

Why the view model receives only the processed string

With this setup, the view model is no longer the direct storage for whatever the keyboard produces. The keyboard writes into internalText. That value is immediately clamped, and only the clamped value is assigned to text (the external binding). Any observer of vm.name sees a single, stable stream of valid values.

There is a second synchronization path for external changes. If the view model sets the string programmatically (resetting the form, restoring a saved draft), onChange(of: text) calls sync(from:) and updates the internal buffer to match the processed value. This keeps the UI consistent and avoids drift between local state and the model.

The output for the “Flower” example will appear as follows:

VM: F
VM: Fl
VM: Flo
VM: Flow

Next steps

The same structure works for more than length limiting. You can replace clamp(_:) with any transformation: trimming whitespace, allowing only digits, normalizing separators, enforcing uppercase, and similar rules. The important part stays the same: the TextField edits a local buffer, and the rest of the app sees only the processed output.

Making the solution universal

Once the internal buffer approach is in place, it becomes easy to turn it into a reusable component. The idea is to separate the mechanism (buffering input and syncing it with the external binding) from the rule that describes how the text should be processed. Instead of hard-coding a max length constraint, the component can accept a transformation closure, for example (String) -> String. The TextField always edits a local @State value, then the closure is applied to produce the final string, and only that processed value is written back to the external @Binding. This design keeps the view model free from intermediate raw input and allows the same TextField to handle trimming, filtering to digits only, uppercasing, or any other formatting rule by swapping the closure.

struct FormattingTextField: View {
    private let title: LocalizedStringKey
    @Binding private var text: String

    /// Transforms raw user input into the value that is allowed to leave this component.
    /// Examples: { String($0.prefix(4)) }, { $0.trimmingCharacters(in: .whitespaces) }, digits-only, etc.
    private let transform: (String) -> String

    @State private var internalText: String = ""

    init(
        _ title: LocalizedStringKey,
        text: Binding<String>,
        transform: @escaping (String) -> String
    ) {
        self.title = title
        self._text = text
        self.transform = transform
    }

    var body: some View {
        TextField(title, text: $internalText)
            .onAppear { 
            		sync(from: text) 
            }

            // User input path: internal -> transformed -> external
            .onChange(of: internalText) { _, newValue in
                sync(from: newValue)
            }

            // External updates path: external -> transformed -> internal
            .onChange(of: text) { _, newValue in
                sync(from: newValue)
            }
    }

    private func sync(from external: String) {
        let processed = transform(raw)

        if internalText != processed {
            internalText = processed
        }
        
        if text != processed {
            text = processed
        }
    }
}
struct ContentView: View {
    
    @State var vm: ViewModel = .init()
    
    var body: some View {
        FormattingTextField("Name", text: $vm.name) {
            String($0.prefix(30))
        }
    }
}

A Practial Note

Rewriting the text programmatically can affect the caret position. When the component replaces internalText with a processed value (for example, when truncating to a max length or stripping characters), SwiftUI may move the cursor to the end or reset the current selection. For simple constraints this is often acceptable, but more advanced formatting (inserting separators, editing in the middle of the string, preserving selection) may require a more specialized approach, such as a UIKit-backed text field.

It is also important to treat transform as part of the component’s contract. The transformation should be deterministic and stable: given the same input it must always return the same output, and it should be idempotent (transform(transform(x)) == transform(x)). These constraints prevent subtle sync loops and help keep the internal buffer and the external binding in a predictable, consistent state.

Available on GitHub.