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:
isSavingis true while an olderrorMessageremains visible.shouldDismissbecomes 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.