Swift and SwiftUI tutorials for Swift Developers

SwiftUI Best Practices

The transition from UIKit and AppKit to SwiftUI has been one of the biggest paradigm shifts in the history of development for Apple platforms. We no longer think in terms of imperative view controllers, but rather in terms of state, data flow, and declarative views.

However, the freedom SwiftUI offers can be a double-edged sword. Without a solid structure, it is easy to end up with “Massive Views,” performance issues, and business logic tightly coupled to the interface. If you are developing for iOS, macOS, and watchOS, consistency is key.

Below, I present the 10 fundamental best practices that every modern Apple developer must implement in their SwiftUI apps and Xcode projects to ensure scalability, maintainability, and optimal performance.


1. Strictly Adopt MVVM (Model-View-ViewModel)

SwiftUI is designed, almost by nature, to work with the MVVM pattern. Trying to force MVC (Model-View-Controller) here usually results in a headache.

Why?

SwiftUI is reactive. The View is simply a function of its state. You need a component that owns that state, processes the business logic, and publishes changes. That component is the ViewModel.

The Golden Rule

The View should not make decisions. The View should only:

  1. Display data.
  2. Capture user interactions.
  3. Pass those interactions to the ViewModel.

Code Example:

// MODEL
struct User: Identifiable {
    let id: UUID
    let name: String
}

// VIEWMODEL (The source of logical truth)
@MainActor
class UserListViewModel: ObservableObject {
    @Published var users: [User] = []
    
    func fetchUsers() async {
        // Load simulation
        self.users = [User(id: UUID(), name: "Ana"), User(id: UUID(), name: "Carlos")]
    }
}

// VIEW
struct UserListView: View {
    @StateObject private var viewModel = UserListViewModel()
    
    var body: some View {
        List(viewModel.users) { user in
            Text(user.name)
        }
        .task {
            await viewModel.fetchUsers()
        }
    }
}

2. Dependency Management and Injection (DI)

To make your application robust and testable, you should never instantiate heavy services (like API clients or database managers) directly inside your Views or ViewModels.

The Practice

Use protocols to define your services and inject them into your ViewModel’s initializer.

protocol DataService {
    func getData() async throws -> [String]
}

class RealDataService: DataService { ... }
class MockDataService: DataService { ... } // For Previews and Tests

class ContentViewModel: ObservableObject {
    let service: DataService
    
    // Dependency Injection
    init(service: DataService = RealDataService()) {
        self.service = service
    }
}

3. Master Property Wrappers (@State vs @StateObject vs @ObservedObject)

The most common mistake among intermediate developers is confusing these wrappers, causing bugs where the UI doesn’t update or, worse, the state resets unexpectedly.

The Definitive Guide:

WrapperWhen to use it
@StateFor simple values (Bool, Int, String) that belong exclusively to a single view (e.g., a local toggle).
@BindingTo read and write a value owned by another parent view.
@StateObjectUse when the View creates and initializes the ViewModel. It is the “owner” of the object.
@ObservedObjectUse when the View receives a ViewModel that was already created elsewhere. Does not guarantee object persistence if the view redraws.
@EnvironmentObjectFor global data shared across the hierarchy (e.g., Auth, Themes).

Critical Note: Never use @ObservedObject to initialize a ViewModel (@ObservedObject var vm = ViewModel()). If the view redraws, your ViewModel will reset, and you will lose data. Use @StateObject instead.


4. View Composition: Divide and Conquer

In UIKit, the problem was the “Massive View Controller.” In SwiftUI, it’s the “Massive Body Property.” If your bodyproperty has more than 50 lines or multiple levels of nesting, it’s time to refactor.

Best Practice

Extract sub-components into small, reusable Views. The SwiftUI compiler is extremely efficient at handling small view hierarchies; in fact, it is often faster than rendering a giant monolithic view.

Bad Example:

var body: some View {
    VStack {
        // 40 lines of code for a profile card
        Image(...)
        Text(...)
        Button(...)
        // ... more code
    }
}

Good Example:

var body: some View {
    VStack {
        ProfileHeaderView(user: user)
        BioSectionView(bio: user.bio)
        ActionButtonsView()
    }
}

This not only improves readability but allows SwiftUI’s rendering system (the diffing engine) to be more precise by updating only the parts that changed.


5. Smart Cross-Platform Design (iOS, macOS, watchOS)

SwiftUI promises “Learn once, apply everywhere,” not necessarily “Write once, run everywhere.” A 1:1 iPhone design rarely looks good on an Apple Watch or a Mac.

Code Sharing Strategy

  1. Shared Logic: Your Models, ViewModels, and Services (Business Layer) should be 100% shared in a Swift Package or shared target.
  2. Adaptive UI: Use conditional compilation (#if os(macOS)) only when strictly necessary.
  3. Flexible Components: Use ViewModifier to adapt styles based on the platform.
// Subtle adaptation example
var body: some View {
    VStack {
        Text("Hello World")
    }
    #if os(iOS)
    .listStyle(.insetGrouped)
    #elseif os(macOS)
    .listStyle(.sidebar) // Native Mac style
    #endif
}

For watchOS, remember to drastically simplify the hierarchy and use NavigationStack in a way that makes sense on a small screen.


6. Efficient Use of Custom Modifiers (ViewModifiers)

If you find yourself copying and pasting the same block of .padding().background(...).cornerRadius(...) five times, you are violating the DRY (Don’t Repeat Yourself) principle.

Create your own ViewModifier to standardize your Design System.

struct PrimaryButton: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .cornerRadius(10)
            .font(.headline)
    }
}

extension View {
    func primaryStyle() -> some View {
        modifier(PrimaryButton())
    }
}

// Usage
Button("Save") { }
    .primaryStyle()

This allows you to change the design of the entire app (e.g., changing the corner radius) by modifying a single file.


7. Performance Optimization and Identity

SwiftUI needs to know which views have changed. Incorrect use of id or non-lazy lists can kill performance, especially in long lists.

Key Practices:

  • Lazy Stacks: Use LazyVStack and LazyHStack inside ScrollView if you have many elements. VStack loads everything into memory immediately; Lazy does it on demand.
  • Stable Identifiers: In List or ForEach, ensure your models conform to Identifiable with a stable ID (like UUID). Do not use array indices (0, 1, 2...) as IDs if the list order can change, as this causes strange animations and rendering bugs.

8. Modern Concurrency: Async/Await and MainActor

Forget completion handlers and nested closures. Since Swift 5.5, async/await is the standard. It is more readable, safer, and handles errors better.

Integration in SwiftUI

Use the .task modifier instead of .onAppear when you need to load asynchronous data when a view appears. .taskautomatically cancels the task if the view disappears before it finishes.

.task {
    do {
        await viewModel.loadData()
    } catch {
        print("Error: \(error)")
    }
}

Additionally, ensure you mark your ViewModels with @MainActor to guarantee that all @Published updates occur on the main thread, avoiding Xcode’s famous purple error (“Publishing changes from background threads is not allowed”).

@MainActor // The entire class runs on the Main Thread
class ProfileViewModel: ObservableObject { ... }

9. Previews with Mock Data

Xcode Previews are your best productivity tool. If you rely on running the app in the simulator to see a color change, you are wasting hours every week.

Pro Tip: Preview Content

Create a “Preview Content” group in your project. Create extensions of your models with static test data.

extension User {
    static let mock = User(id: UUID(), name: "Demo User", email: "demo@test.com")
}

// In your view
#Preview {
    ProfileView(user: .mock)
}

This makes your views pure and isolated. If your view doesn’t work in the Preview, it probably has hidden dependencies that you need to refactor.


10. Accessibility and Localization from Day 1

Don’t leave this for last. SwiftUI makes accessibility almost automatic, but you must guide it.

  • Dynamic Type: Do not use fixed font sizes (.font(.system(size: 16))). Use semantic styles (.font(.body).font(.headline)). This ensures the app respects the text size chosen by the user in system settings.
  • VoiceOver: Use .accessibilityLabel() and .accessibilityHint() on elements that are not standard text (like icons or images).
  • String Catalogs: Use Xcode’s new String Catalogs system (introduced in Xcode 15) to manage your text. Avoid hardcoding strings in the UI.
Text("welcome_message") // Localized key
    .font(.title) // Supports dynamic scaling

Conclusion

Developing for the Apple ecosystem with SwiftUI and Xcode is a rewarding experience when the correct guidelines are followed. Adopting MVVM, understanding the state lifecycle, intelligently sharing code between iOS, macOS, and watchOS, and maintaining a clean architecture through dependency injection will not only make your code more professional but will also make your life as a developer much easier as the project grows.

SwiftUI evolves every year (WWDC after WWDC). Keeping these foundations strong will allow you to adapt to future changes without having to rewrite your application from scratch.

f you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

How to install GitHub Copilot in Xcode for Swift and SwiftUI

Next Article

How to display an image from URL in SwiftUI

Related Posts