Modern SwiftUI Engineering Reference
🟪 Syntax · 🟦 Types/APIs · 🟩 Correct pattern · 🟥 Bug · 🟨 Gotcha · 🧠 Explanation
Current baseline
This guide targets current SwiftUI code with the Swift 6.4 compiler and iOS 27 SDK. Your app's deployment target still determines which APIs you can call without availability checks. Observation and SwiftData require iOS 17; NavigationStack requires iOS 16. Use #available or keep a compatible older implementation when supporting earlier systems.
State ownership in one table
| Wrapper/API | Owns storage? | Main use |
|---|---|---|
@State | Yes, for view-local value or reference state | Selection, form values, presentation state, or an owned @Observable model |
@Binding | No | Child reads/writes a value owned by a parent |
@Observable | Model declaration macro | Makes properties of a reference type observable |
@Bindable | No | Creates bindings to properties on an @Observable model |
@Environment | No | Reads a system value or dependency injected into the view hierarchy |
@StateObject | Yes, for ObservableObject | Owns a legacy ObservableObject reference |
@ObservedObject | No | Observes an injected legacy ObservableObject |
@EnvironmentObject | No | Reads a shared legacy ObservableObject from the environment |
Modern Observation model
import SwiftUI
import Observation
@MainActor
@Observable
final class ProfileModel {
var name = "Taylor"
var isSaving = false
func save() async {
isSaving = true
defer { isSaving = false }
// Call the service and update model state.
}
}
struct ProfileScreen: View {
@State private var model = ProfileModel()
var body: some View {
@Bindable var model = model
Form {
TextField("Name", text: $model.name)
Button("Save") {
Task { await model.save() }
}
.disabled(model.isSaving)
}
}
}SwiftUI tracks observable properties that a view reads. @Bindable is for forming bindings, not for ownership. Keep a model's state on the main actor when it directly drives UI and is mutated by UI actions.
Pass data down and actions up
Use a Binding when the child edits a value owned by its parent, pass a model when the child needs a meaningful set of operations, and pass a closure for one focused action. The full question-by-question explanation and declaration examples are in State Management → @Binding.
Environment dependencies
@Observable
final class Session {
var displayName = "Guest"
}
@main
struct ExampleApp: App {
@State private var session = Session()
var body: some Scene {
WindowGroup {
HomeScreen()
.environment(session)
}
}
}
struct HomeScreen: View {
@Environment(Session.self) private var session
var body: some View {
Text("Hello, \(session.displayName)")
}
}Use the environment for genuinely shared app/session dependencies. Use initializer injection when a dependency is feature-specific or should be obvious at the call site. Missing required environment values are configuration errors, so install them at the app or feature root and make previews provide them too.
State Management
@State
What is @State?
@State gives SwiftUI-managed storage owned by a view identity. Use it for small local values or for an @Observable model that this view creates and owns. SwiftUI preserves that storage across ordinary view-value recreation while the view identity remains stable; changing identity can reset it.
struct CounterView: View {
@State private var count = 0
var body: some View {
Button("Count: \(count)") { count += 1 }
}
}Do not use @State as a global store or recreate long-lived reference state in a transient child. For an injected editable value, use @Binding instead.
@Binding
What is @Binding?
Short answer: @Binding gives a view read/write access to a value whose storage is owned elsewhere. It is a connection to the source of truth, not a second owned copy.
The parent owns the value with @State (or another binding-capable source) and passes the projected binding using $. The child declares @Binding; reads use the wrapped value, and writes go back through the binding to the owner. This lets SwiftUI update the views that depend on the changed state.
How do you declare and pass a Binding?
import SwiftUI
struct ParentView: View {
@State private var username = ""
var body: some View {
UsernameEditor(username: $username)
}
}
struct UsernameEditor: View {
@Binding var username: String
var body: some View {
TextField("Username", text: $username)
}
}The initializer receives Binding<String> at the call site as username: $username. With a custom initializer, assign the wrapper storage (_username = username); do not try to construct a protocol-like Binding with Binding().
How do you create a custom Binding?
Use Binding(get:set:) to adapt an existing source of truth or expose a derived editable value. Keep both closures connected to the same owner and make the setter's behavior explicit.
struct SettingsToggle: View {
@State private var isEnabled = false
var body: some View {
let enabled = Binding<Bool>(
get: { isEnabled },
set: { isEnabled = $0 }
)
Toggle("Enabled", isOn: enabled)
}
}Prefer a direct projected binding such as $username when possible. A custom binding is an adapter, not storage; it does not make a temporary value persistent or establish ownership.
When should a child use @Binding?
Use it when the child both displays and edits a specific piece of parent-owned state, such as a text field, toggle, selection, or presentation flag. Use a plain let when the child only reads a value, a closure when it only reports an action, and a model when the child needs cohesive behavior or several related operations.
@State vs @Binding: who owns the value?
@State | @Binding | |
|---|---|---|
| Owns storage? | Yes, scoped to the view identity. | No; refers to storage owned elsewhere. |
| Typical role | Source of truth in the owning view. | Editable access passed to a child. |
| Projection | $value creates a binding. | The property is already a binding; $value can pass its projected wrapper onward. |
Do not add @State to a child merely to make an input editable; that creates independent child state that can diverge from the parent.
@Binding vs @Bindable: which one fits?
@Binding is a property wrapper for a value binding supplied from outside, commonly a parent-owned scalar or a binding already created elsewhere. @Bindable works with an @Observable reference model and lets a view form bindings to that model's mutable properties. Neither wrapper owns the underlying model. Use @State at the owning boundary when the view creates the model.
Can a Binding be initialized manually?
Yes, with a meaningful getter and setter, for example Binding(get:set:), or by forwarding an existing binding in a custom initializer. SwiftUI also offers constant bindings for previews or read-only controls. Do not fabricate a binding around a local temporary variable: the source must outlive the view and remain the real source of truth.
Common @Binding mistakes
- Passing
usernameinstead of$usernamewhen the child needs a binding. - Declaring child state with
@Stateand accidentally creating a second source of truth. - Using a binding for a one-way value or action when a
letor closure is clearer. - Capturing short-lived or unsynchronized mutable data in a custom getter/setter.
- Treating
@Bindingas an observable model, persistence mechanism, or cross-thread synchronization tool.
View identity and rendering
- A SwiftUI
Viewis a value describing UI. SwiftUI can recreate that value often; do not start network requests or create long-lived side effects inbody. @Statestorage is associated with a view's identity. Changing.id(...)creates a new identity and can reset state.- In
ForEachandList, use stable identities (Identifiableor stable IDs), never array offsets for mutable server data. - Keep
bodydeclarative and small. Extract meaningful reusable UI into aViewtype; use@ViewBuilderfor small conditional content slots. - Modifiers wrap views. Modifier order matters: for example, padding before a background differs from background before padding.
- Avoid
AnyViewunless type erasure is truly needed; conditionals and@ViewBuilderalready support many different child branches.
struct ProductRow: View {
let product: Product
var body: some View {
HStack {
Text(product.name)
Spacer()
Text(product.price, format: .currency(code: "USD"))
}
.padding(.vertical, 8)
}
}
struct ProductList: View {
let products: [Product]
var body: some View {
List(products) { product in
ProductRow(product: product)
}
}
}Lifecycle work and cancellation
Use .task for asynchronous work tied to a view's presence. Use .task(id:) for work that must restart when an input changes, such as search. SwiftUI can cancel the task when the view disappears or the ID changes; cancellation is cooperative, so called code should honor cancellation too.
struct SearchScreen: View {
@State private var query = ""
@State private var results: [SearchResult] = []
let search: (String) async throws -> [SearchResult]
var body: some View {
List(results) { result in
Text(result.title)
}
.searchable(text: $query)
.task(id: query) {
let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else {
results = []
return
}
do {
try await Task.sleep(for: .milliseconds(300))
try Task.checkCancellation()
results = try await search(normalized)
} catch is CancellationError {
// A newer query owns the screen now.
} catch {
results = []
}
}
}
}For a real screen, expose loading, empty, content, and failure as explicit UI states; don't treat every error as an empty result. Put request policy in a model/service, not in a large view body.
State-driven navigation
NavigationStack supports typed values and a path that the app can save, inspect, or update. Keep route values small and stable—usually IDs—not full mutable domain models.
enum Route: Hashable, Codable {
case product(UUID)
case account
}
struct CatalogNavigation: View {
@State private var path: [Route] = []
let products: [Product]
var body: some View {
NavigationStack(path: $path) {
List(products) { product in
NavigationLink(product.name, value: Route.product(product.id))
}
.navigationTitle("Catalog")
.navigationDestination(for: Route.self) { route in
switch route {
case .product(let id):
ProductDetailsScreen(productID: id)
case .account:
AccountScreen()
}
}
}
}
}Use NavigationSplitView when the interface has coordinated columns, such as a sidebar and detail view. Avoid new NavigationView code for stack navigation. Deep links should validate route input and resolve IDs through the data layer.
Layout, accessibility, and platform fit
- Use
VStack,HStack, andZStackfor simple composition; useLazyVStack/LazyVGridfor large scrolling content. - Let text wrap and respect Dynamic Type. Avoid fixed heights for text-heavy controls.
- Add meaningful labels and hints to custom controls; maintain sufficient hit targets and contrast.
- Prefer semantic system colors, materials, and platform controls so the system appearance adapts. Test both light and dark appearances and different size classes.
- Respect safe areas deliberately. Use
safeAreaInsetfor persistent controls that should reserve space. - Follow the current platform design language rather than drawing a system surface by hand when a native control already provides it.
SwiftData in a SwiftUI feature
import SwiftData
@Model
final class SavedArticle {
@Attribute(.unique) var id: UUID
var title: String
var savedAt: Date
init(id: UUID = UUID(), title: String, savedAt: Date = .now) {
self.id = id
self.title = title
self.savedAt = savedAt
}
}
struct SavedArticlesScreen: View {
@Query(sort: \SavedArticle.savedAt, order: .reverse)
private var articles: [SavedArticle]
var body: some View {
List(articles) { article in
Text(article.title)
}
}
}Install .modelContainer(for:) at the app or feature root. @Query is a SwiftUI-facing fetch; use ModelContext and FetchDescriptor for imperative fetches and operations outside a view. Plan schema migration and test persistence behavior instead of assuming a model edit is harmless.
Previews and availability
#Preview("Profile") {
ProfileScreen()
}
if #available(iOS 17, *) {
// Observation or SwiftData path.
} else {
// Compatibility path for an older deployment target.
}Make previews deterministic: inject sample models and fake services, avoid production network calls, and keep preview data representative of empty, loading, error, and content states.
Quick migration map from older SwiftUI
| Older pattern | Modern direction |
|---|---|
ObservableObject + @Published for new iOS 17+ apps | @Observable model; @State for owner; @Bindable for edits |
@StateObject / @ObservedObject | Keep for legacy ObservableObject code; don't wrap an @Observable model with them |
NavigationView stack | NavigationStack; use NavigationSplitView for columns |
Networking in onAppear | .task / .task(id:) with cancellation-aware async work |
| Big view with nested conditionals | Explicit screen state and small composed views |
