← All writing

SwiftUI State Management Without Losing User Intent

SwiftUI state bugs are often user-intent bugs in disguise. Here is the model I use to keep loading, saving, errors, and navigation explicit in modern iOS 17 apps.


Most SwiftUI state problems do not begin with a property wrapper. They begin with a user action that the application has not modelled clearly enough.

A person taps Save. The button spins. They tap again because nothing else changed. The request finishes after they navigate away. An error arrives with no useful retry path. The view has several booleans, a task created in a button action, and just enough state to make the screen look correct until timing gets involved.

The real question is not “Should this be @State or @Environment?” It is “What does the person intend to do, and what states can that intent move through?”

Once the flow is explicit, SwiftUI’s state tools become easier to choose. Apple describes @State as storage managed by SwiftUI for a view hierarchy, and its modern Observation model gives views fine-grained dependencies on the properties they actually read. Apple’s model-data guidance is a useful reference. The design work is deciding where the source of truth belongs.

Model the action before the screen

Consider a profile editor. A loose implementation often starts like this:

@State private var isSaving = false
@State private var errorMessage: String?
@State private var shouldDismiss = false

Those values are not wrong. They are easy to combine into invalid states:

  • isSaving is true while an old errorMessage remains visible.
  • shouldDismiss becomes true after a retry has already started.
  • Two button taps start two saves.

I prefer one state that describes the lifecycle of the user action:

enum SaveState: Equatable {
    case idle
    case saving
    case failed(message: String)
    case saved
}

Now the screen cannot be both saving and failed unless the type says it can. The UI can render each state intentionally, and a test can ask a simple question: given this result from the service, what state should the person see next?

Keep the source of truth with the feature

For iOS 17 and later, an @Observable model stored in @State is a clean fit when a view owns feature state. The model contains the mutable state and the action. The view stays focused on input, output, and navigation.

import Observation
import SwiftUI

protocol ProfileSaving: Sendable {
    func save(displayName: String) async throws
}

@Observable
@MainActor
final class ProfileEditorModel {
    var displayName: String
    private(set) var saveState: SaveState = .idle

    private let profileService: any ProfileSaving

    init(displayName: String, profileService: any ProfileSaving) {
        self.displayName = displayName
        self.profileService = profileService
    }

    var canSave: Bool {
        !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            && saveState != .saving
    }

    func save() async {
        guard canSave else {
            return
        }

        saveState = .saving

        do {
            try await profileService.save(displayName: displayName)
            saveState = .saved
        } catch is CancellationError {
            saveState = .idle
        } catch {
            saveState = .failed(message: "We could not save your changes.")
        }
    }

    func dismissError() {
        guard case .failed = saveState else {
            return
        }

        saveState = .idle
    }
}

@MainActor makes the model’s UI state safe to update from its asynchronous action. The service can do its network or persistence work without giving every view responsibility for thread hops and error conversion. The model turns technical failure into a state the screen knows how to present.

The model is intentionally not global. A profile editor owns profile-editing state. A booking flow owns booking state. Shared application-wide dependencies belong in the environment only when they genuinely have an application-wide lifetime.

Bind the form, not the whole architecture

The view creates the model once and takes a binding only where an editable field needs it.

struct ProfileEditorView: View {
    @Environment(\.dismiss) private var dismiss
    @State private var model: ProfileEditorModel

    init(displayName: String, profileService: any ProfileSaving) {
        _model = State(
            initialValue: ProfileEditorModel(
                displayName: displayName,
                profileService: profileService
            )
        )
    }

    var body: some View {
        @Bindable var model = model

        Form {
            TextField("Display name", text: $model.displayName)

            if case let .failed(message) = model.saveState {
                Text(message)
                    .foregroundStyle(.red)
            }

            Button("Save") {
                Task {
                    await model.save()
                }
            }
            .disabled(!model.canSave)
        }
        .onChange(of: model.saveState) { _, state in
            if state == .saved {
                dismiss()
            }
        }
    }
}

@Bindable provides the projected binding for displayName without turning the full model into a collection of ad hoc bindings. The important part is not the syntax. The button can only start the action when canSave permits it, and the view only dismisses after the model has entered the explicit saved state.

If the feature needs the same model in several child views, pass the model to them. If a child edits a property, make a local @Bindable reference in that child. Do not push a simple editing model into a global environment because several screens happen to sit under the same navigation stack.

Let cancellation preserve intent

SwiftUI cancels work started with task(id:) when the ID changes or the view disappears, but cancellation is cooperative. A network call may finish after a person leaves the screen. A Task started from a Save button is different: decide deliberately whether saving should finish after dismissal or whether the feature should retain and cancel that task.

This matters for more than a Save button. Search, detail loading, and filtered lists all have a changing input. Use task(id:) when a task should restart for a new input, then treat cancellation as normal control flow instead of displaying it as an error.

.task(id: query) {
    await model.search(for: query)
}

The model should discard results that no longer match the current query. Otherwise a slow response for “ca” can overwrite the newer results for “calendar”. The issue looks like a rendering bug. It is really the app showing the result of an intention the person has already changed.

Keep errors recoverable and specific

An error message should tell the person what action remains available. “Something went wrong” does not.

At the model boundary, map known failures to product language:

No connection: Keep the draft and offer Retry.
Validation failure: Keep the form visible and point to the invalid field.
Permission failure: Explain the missing access and offer the relevant settings path.
Unexpected failure: Preserve the input, show a safe message, and make retry possible.

This is another reason to avoid throwing raw service errors into the view. A URL error, a decoding error, and a rejected update may all be Error to Swift, but they are not the same next step for a person using the app.

A small state ownership guide

I use a simple ownership rule before reaching for a wrapper:

Local visual detail       @State
Child can edit parent data @Binding
Feature-owned mutable model @Observable stored in @State
Shared app dependency     @Environment
Read-only input           let property

The list is not a substitute for design. It is a reminder to start with ownership and lifetime. If a value has no clear owner, putting it in a wrapper only hides the uncertainty.

Test the transitions, not the view internals

The highest-value tests exercise the feature model:

Given valid input, save moves idle → saving → saved.
Given a service failure, save moves idle → saving → failed and keeps the input.
Given cancellation, save does not dismiss the screen or report a false success.
Given an empty name, save does not start a request.

Those tests do not care whether the screen uses a Form, a sheet, or a custom button style. They protect the user intent the interface is supposed to preserve.

SwiftUI gets much calmer when the state model matches the action a person took. A save is not a Boolean. A request is not automatically a success. A dismissed view is not proof that data persisted. Make those distinctions in the model, and the views become smaller, the async work becomes easier to reason about, and the app stops pretending that timing is not part of the product.