SwiftUI Best Practices
Follow these rules when authoring SwiftUI. Prefer modern patterns unless constrained by OS targets.
View File Organization
- Keep feature-level SwiftUI views in their own source files.
- Do not define feature screens, sheets, or reusable subviews inside an unrelated parent view file.
- Small private helper views may stay in the parent view file only when they are tightly coupled to that parent and not useful elsewhere.
- View models should not be declared in SwiftUI view files. Put them in separate files under the feature’s
ViewModelsfolder. - Prefer feature folders that group related SwiftUI files together, for example:
Timeline/Settings/Views/SettingsView.swiftTimeline/Settings/ViewModels/SettingsViewModel.swift
State & data flow
- Keep SwiftUI views stateless where possible.
- Put business logic in a ViewModel or model layer; views bind to simple state.
- Prefer one clear source of truth; avoid duplicating state (e.g.,
@Statemirroring model fields) unless it’s an intentional edit buffer. - Avoid passing bindings deep through many layers; consider a small view model, environment objects, or feature state.
Observation bindings
For
@Observablereference models owned by a view with@State, use a local@Bindablewrapper insidebodywhen passing bindings to SwiftUI controls or presentation modifiers:var body: some View { @Bindable var viewModel = viewModel content .sheet(isPresented: $viewModel.isPresented) { SheetView() } }Prefer
@Bindableover manualBinding(get:set:)when the binding maps directly to a mutable view-model property.Use manual
Binding(get:set:)only when setting the value must invoke custom logic that cannot be represented by a property setter.If a computed view-model property needs binding behavior, make it settable and route the setter through the existing intent method:
var isPresented: Bool { get { coordinator.isPresented } set { onPresentationChange(isPresented: newValue) } }Avoid reintroducing
ObservableObject,@Published,@StateObject, or@ObservedObjectjust to recover$model.propertysyntax. Use@Observable,@State, and@Bindablefor new SwiftUI code unless maintaining legacy code.
Alerts and presentation state
- Model multiple mutually-exclusive alerts with a single enum-backed state, not a growing set of
show...Alertbooleans. - Use associated values to carry item-specific context for the alert.
- Prefer one derived alert/template surface per feature instead of many alert-template properties.
- Prefer one
.alert(...)modifier per feature/view model when alerts are mutually exclusive. - Repeated booleans for alerts or confirmation dialogs are usually a sign that the presentation state should be refactored into an enum.
- If an alert needs dependencies only available in the view layer, keep the alert state in the view model and pass the dependency into a single alert-builder method or expose a compact action model for the view to adapt once.
View composition
- If a
bodygrows large, extract subviews:- Use a computed var only for small, local snippets (roughly < 15 lines).
- Use ViewBuilder tagged helper methods if data needs to be supplied to create the subview
- Use a private nested View struct for substantial UI chunks.
- Keep modifiers close to the view they affect; avoid huge modifier chains by extracting view builders.
- Avoid deprecated SwiftUI APIs when a supported replacement exists for the project’s deployment targets.
- When replacing deprecated SwiftUI APIs, preserve existing behavior unless the task explicitly calls for a behavioral change.
Identity & lists
- Use stable identity in lists:
ForEach(items, id: \.id)orIdentifiable. - Avoid using indices as IDs unless the list is truly static.
- Don’t generate new UUIDs in
bodyfor identity.
Navigation
- Prefer
NavigationStack(andNavigationSplitViewon iPad/macOS) over legacy APIs. - Avoid pushing navigation state into many views; keep navigation decisions close to the feature boundary.
- Prefer typed navigation (routes as enums / Hashable) rather than stringly-typed paths.
Performance pitfalls
- Keep
bodypure; do not start async work directly inbody. - Use
.task(id:)for async work that depends on changing inputs. - Avoid heavy work in computed properties that run during rendering.
- Use
@Statefor local UI state; avoid@ObservedObjectunless required by legacy patterns.
Animations
- Prefer explicit animations near the state change (
withAnimation { ... }) rather than global.animation(...)modifiers. - Use
.transactionsparingly, with intent.
Accessibility
- Provide meaningful labels for icons and custom controls.
- Ensure tappable areas are sufficient (contentShape / padding where needed).
- Don’t rely on color alone to convey meaning.
Output format (when generating SwiftUI code)
- Provide complete, compiling SwiftUI code.
- If introducing a ViewModel, keep it minimal and test-friendly.
- Add brief comments only where behavior is non-obvious.