SwiftUI Patterns
Use these patterns to build clear and fast SwiftUI apps.
This skill covers:
- State with the Observation framework
- Small, focused views
- Safe navigation
- Fast lists and layouts
- Shared app services
- Previews and test data
Credit: Keep the original author's name, license, and credit in every copy or fork of this skill.
When to Use This Skill
Use this skill when you:
- Build or change a SwiftUI view
- Pick between
@State,@Binding,@Bindable, and@Environment - Build a model with
@Observable - Add a flow with
NavigationStack - Pass data or services between views
- Fix a slow list or layout
- Add SwiftUI previews
Platform Check
The Observation framework needs iOS 17, macOS 14, or a newer system.
For an older system, use ObservableObject, @Published, @StateObject, and @EnvironmentObject. Do not replace them unless the app can raise its minimum system version.
Import Observation when you use @Observable:
import Observation
import SwiftUIState
Pick the Smallest Tool
| Tool | Use it for |
|---|---|
@State |
Data owned by one view |
@Binding |
Read and change state owned by a parent view |
@State with an @Observable class |
A model owned by the view |
Plain let or var |
An @Observable model passed by a parent when no binding is needed |
@Bindable |
Bind a control to a field in an @Observable model |
@Environment |
A shared model or service passed with .environment() |
Do not copy parent data into @State unless the view must own a separate draft. A copied value will not stay in sync with the parent.
Keep UI Models on the Main Actor
A model that changes UI state should run on the main actor.
@MainActor
@Observable
final class ItemListModel {
private(set) var items: [Item] = []
private(set) var isLoading = false
private(set) var errorMessage: String?
var searchText = ""
private let repository: any ItemRepository
init(repository: any ItemRepository) {
self.repository = repository
}
func load() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
items = try await repository.fetchAll()
} catch is CancellationError {
return
} catch {
errorMessage = "Items could not be loaded."
}
}
}Do not hide every error with try?. Show a safe message or store the error state.
Own a Model in a View
Create owned state once. Pass needed services through the initializer.
struct ItemListView: View {
@State private var model: ItemListModel
init(repository: any ItemRepository) {
_model = State(
initialValue: ItemListModel(repository: repository)
)
}
var body: some View {
@Bindable var model = model
List(model.items) { item in
ItemRow(item: item)
}
.searchable(text: $model.searchText)
.overlay {
if model.isLoading {
ProgressView()
}
}
.task {
await model.load()
}
.alert(
"Could Not Load Items",
isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(model.errorMessage ?? "")
}
}
}A .task is stopped when the view leaves the screen. The work inside it must also handle task cancelation.
Use .task(id:) when work must run again after a value changes:
.task(id: model.searchText) {
await model.search()
}The search method should check for cancelation before it saves results.
Pass a Model to a Child
Use a plain property when the child only reads the model:
struct ItemCountView: View {
let model: ItemListModel
var body: some View {
Text("\(model.items.count) items")
}
}Use @Bindable when the child needs a binding:
struct SearchField: View {
@Bindable var model: ItemListModel
var body: some View {
TextField("Search", text: $model.searchText)
}
}Use a Binding for a Single Value
struct NameField: View {
@Binding var name: String
var body: some View {
TextField("Name", text: $name)
}
}Put Shared Models in the Environment
@MainActor
@Observable
final class AuthManager {
var currentUser: User?
}Inject it near the app root:
ContentView()
.environment(authManager)Read it in a child view:
struct ProfileView: View {
@Environment(AuthManager.self) private var auth
var body: some View {
Text(auth.currentUser?.name ?? "Guest")
}
}The app will fail at run time if the value is missing. Add the same environment value to previews and tests.
Do not use the environment for every value. Use it for data that is truly shared by a large part of the app.
View Design
Make Views Small
Split a large view into small views with clear inputs.
struct OrderView: View {
@State private var model = OrderModel()
var body: some View {
VStack {
OrderHeader(title: model.title)
OrderItemList(items: model.items)
OrderTotal(total: model.total)
}
}
}Pass only the data each child needs. This makes the code easier to read and can reduce extra view work.
Do not split every short block into a new type. Split code when the part has its own job, state, layout, or reuse.
Use a View Modifier for Shared Style
struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
extension View {
func cardStyle() -> some View {
modifier(CardModifier())
}
}Use @ViewBuilder for small view choices:
@ViewBuilder
private var content: some View {
if items.isEmpty {
EmptyView()
} else {
ItemList(items: items)
}
}Avoid AnyView unless stored views must have one fixed type.
Navigation
Use a Hashable route type. Keep route data small. Pass an ID instead of a full model when the next screen can load the item.
enum Destination: Hashable {
case detail(Item.ID)
case settings
case profile(User.ID)
}
@MainActor
@Observable
final class Router {
var path = NavigationPath()
func go(to destination: Destination) {
path.append(destination)
}
func goBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
func goToRoot() {
path = NavigationPath()
}
}struct RootView: View {
@State private var router = Router()
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Destination.self) { destination in
switch destination {
case .detail(let id):
ItemDetailView(itemID: id)
case .settings:
SettingsView()
case .profile(let id):
ProfileView(userID: id)
}
}
}
.environment(router)
}
}Each route value must be Hashable. Register each route type with navigationDestination.
Use a typed array instead of NavigationPath when the stack holds only one route type:
var path: [Destination] = []This is easier to save, restore, and test.
Speed
Use Lazy Layouts for Large Scroll Views
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items) { item in
ItemRow(item: item)
}
}
}List is often a good first choice for long lists. Use a lazy stack when you need more layout control.
Use Stable IDs
Each row needs an ID that stays the same while the item exists.
ForEach(items, id: \.stableID) { item in
ItemRow(item: item)
}Do not use an array index as the ID when rows can move, change, or be removed. Do not make a new UUID each time body runs.
Keep Work Out of body
Do not do these tasks in body:
- File reads
- Web calls
- Data saves
- Large sorts or filters
- Image decode work
- Date formatter setup for every row
Load data in a model or task. Save a computed result when the source data changes.
Use .task for async work. Use .onChange for a small action caused by a value change.
Limit Costly Effects
Use these with care in long or moving lists:
.blur().mask()- Large shadows
- Many layers of clear or see-through views
- Geometry reads on every row
- Feedback that runs during scroll
Test on a real device. Preview speed is not the same as app speed.
Use Equatable Views Only After You Measure
For a costly view, Equatable plus .equatable() may skip work when its input did not change.
struct ExpensiveChartView: View, Equatable {
let dataPoints: [DataPoint]
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.dataPoints == rhs.dataPoints
}
var body: some View {
ChartContent(dataPoints: dataPoints)
}
}ExpensiveChartView(dataPoints: points)
.equatable()All compared data must have correct equality rules. Do not add this to every view. First use Instruments or another clear test to find the slow part.
Previews
Use small fake data sets. Cover key states.
#Preview("Empty") {
ItemListView(repository: EmptyItemRepository())
}
#Preview("Loaded") {
ItemListView(repository: SampleItemRepository())
}
#Preview("Dark") {
ItemListView(repository: SampleItemRepository())
.preferredColorScheme(.dark)
}Also preview:
- Loading
- Error
- Long text
- Large text size
- Right-to-left text when the app supports it
- Small and large screens
- Missing optional data
Add all needed environment values to each preview.
Full Usage Example
This example owns a model, loads data, edits a search field, shows an error, and opens a detail screen.
import Observation
import SwiftUI
struct Item: Identifiable, Hashable, Sendable {
let id: UUID
let name: String
}
protocol ItemRepository: Sendable {
func fetchAll() async throws -> [Item]
}
struct SampleItemRepository: ItemRepository {
func fetchAll() async throws -> [Item] {
[
Item(id: UUID(), name: "Book"),
Item(id: UUID(), name: "Lamp")
]
}
}
@MainActor
@Observable
final class ItemListModel {
private(set) var items: [Item] = []
private(set) var errorMessage: String?
var searchText = ""
private let repository: any ItemRepository
init(repository: any ItemRepository) {
self.repository = repository
}
var shownItems: [Item] {
guard !searchText.isEmpty else { return items }
return items.filter {
$0.name.localizedCaseInsensitiveContains(searchText)
}
}
func load() async {
do {
items = try await repository.fetchAll()
} catch is CancellationError {
return
} catch {
errorMessage = "Please try again."
}
}
}
struct ItemListView: View {
@State private var model: ItemListModel
init(repository: any ItemRepository) {
_model = State(
initialValue: ItemListModel(repository: repository)
)
}
var body: some View {
@Bindable var model = model
NavigationStack {
List(model.shownItems) { item in
NavigationLink(value: item) {
Text(item.name)
}
}
.navigationTitle("Items")
.searchable(text: $model.searchText)
.navigationDestination(for: Item.self) { item in
Text(item.name)
.navigationTitle("Item")
}
.task {
await model.load()
}
}
}
}
#Preview {
ItemListView(repository: SampleItemRepository())
}For a very large list, move search work out of the computed property. Run it when the search text or item list changes.
Avoid These Problems
- Do not use Observation APIs below their supported system version.
- Do not start async work in
body. - Do not start long work in
init. - Do not create owned models again during each view update.
- Do not put a passed model in
@Stateunless the child must own it. - Do not copy a binding value into state and expect both values to stay in sync.
- Do not change UI state from a background actor.
- Do not ignore task cancelation.
- Do not use changing or duplicate list IDs.
- Do not place large work in a computed property read by
body. - Do not use
AnyViewfor simple view choices. - Do not forget
Sendablerules when data crosses actor lines. - Do not add shared data to the environment when a normal argument is enough.
- Do not force unwrap data that may be missing.
Related Skills
For actor-based data storage, see swift-actor-persistence.
For protocol-based setup and Swift Testing, see swift-protocol-di-testing.