SwiftUI Best Practices 2025: Professional Patterns & Tips
SwiftUI has matured significantly since its introduction. After building numerous production apps with SwiftUI, I've identified the patterns and practices that lead to maintainable, performant, and scalable code. This guide covers everything from architecture to state management to performance optimization.
1. Architecture Patterns for SwiftUI
The right architecture makes your app easier to test, maintain, and extend. Here are the most effective patterns for SwiftUI:
MVVM (Model-View-ViewModel)
MVVM remains the most natural fit for SwiftUI's declarative paradigm:
// Model
struct User: Codable, Identifiable {
let id: UUID
var name: String
var email: String
}
// ViewModel
@MainActor
class UserViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
@Published var error: Error?
private let userService: UserServiceProtocol
init(userService: UserServiceProtocol = UserService()) {
self.userService = userService
}
func fetchUsers() async {
isLoading = true
defer { isLoading = false }
do {
users = try await userService.getUsers()
} catch {
self.error = error
}
}
}
// View
struct UserListView: View {
@StateObject private var viewModel = UserViewModel()
var body: some View {
List(viewModel.users) { user in
UserRow(user: user)
}
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
.task {
await viewModel.fetchUsers()
}
}
}
The Composable Architecture (TCA)
For complex apps, consider TCA for unidirectional data flow:
- Single source of truth for state
- Highly testable with predictable state changes
- Better handling of side effects
- Built-in dependency injection
Best Practice: Start with MVVM for simpler apps. Consider TCA when you have complex state interactions, need comprehensive testing, or have a larger team.
2. State Management
Choosing the right property wrapper is crucial for performance and correctness:
| Property Wrapper | Use Case |
|---|---|
@State |
Simple, view-local value types (Bool, String, Int) |
@Binding |
Pass mutable state to child views |
@StateObject |
Create and own an ObservableObject |
@ObservedObject |
Reference an ObservableObject you don't own |
@EnvironmentObject |
Share state across view hierarchy |
@Environment |
Access system-provided values |
Common Mistakes to Avoid
// WRONG: Creates new ViewModel on every view update
struct BadView: View {
@ObservedObject var viewModel = ViewModel() // Don't do this!
var body: some View { ... }
}
// CORRECT: ViewModel persists across updates
struct GoodView: View {
@StateObject private var viewModel = ViewModel()
var body: some View { ... }
}
Observable Macro (iOS 17+)
Use the new @Observable macro for cleaner code:
@Observable
class UserStore {
var currentUser: User?
var isLoggedIn: Bool { currentUser != nil }
func login(email: String, password: String) async throws {
currentUser = try await authService.login(email: email, password: password)
}
}
3. View Composition
Breaking down views improves readability, reusability, and performance:
Extract Subviews
// Before: Monolithic view
struct ProfileView: View {
var body: some View {
VStack {
// 200 lines of header code
// 150 lines of stats code
// 300 lines of activity code
}
}
}
// After: Composed views
struct ProfileView: View {
var body: some View {
VStack {
ProfileHeader(user: user)
ProfileStats(stats: stats)
ActivityFeed(activities: activities)
}
}
}
ViewBuilder for Custom Containers
struct Card: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 4)
}
}
// Usage
Card {
VStack {
Text("Title")
Text("Subtitle")
}
}
4. Navigation Best Practices
NavigationStack (iOS 16+)
Use the modern navigation API for type-safe, programmatic navigation:
enum Route: Hashable {
case userDetail(User)
case settings
case editProfile
}
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .userDetail(let user):
UserDetailView(user: user)
case .settings:
SettingsView()
case .editProfile:
EditProfileView()
}
}
}
}
func navigateToUser(_ user: User) {
path.append(Route.userDetail(user))
}
}
Deep Linking Support
extension Route {
init?(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return nil
}
switch components.path {
case "/settings":
self = .settings
case let path where path.hasPrefix("/user/"):
let userId = String(path.dropFirst(6))
// Fetch user and create route
self = .userDetail(User(id: userId))
default:
return nil
}
}
}
5. Performance Optimization
Lazy Loading
Always use lazy containers for large data sets:
// Use LazyVStack instead of VStack for long lists
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
// LazyVGrid for grid layouts
LazyVGrid(columns: columns, spacing: 16) {
ForEach(photos) { photo in
PhotoThumbnail(photo: photo)
}
}
Preventing Unnecessary Redraws
// Implement Equatable to prevent unnecessary updates
struct UserRow: View, Equatable {
let user: User
static func == (lhs: UserRow, rhs: UserRow) -> Bool {
lhs.user.id == rhs.user.id &&
lhs.user.name == rhs.user.name
}
var body: some View {
HStack {
Text(user.name)
Spacer()
}
}
}
// Use .equatable() modifier
ForEach(users) { user in
UserRow(user: user)
.equatable()
}
Drawing Group for Complex Views
// Flatten complex view hierarchies for better GPU performance
ComplexAnimatedView()
.drawingGroup()
Warning: Don't use drawingGroup() everywhere. It creates a bitmap, which uses more memory. Only use it for complex views with many layers or animations.
6. Async/Await Integration
Task Modifier
struct ContentView: View {
@State private var data: [Item] = []
var body: some View {
List(data) { item in
ItemRow(item: item)
}
.task {
// Automatically cancelled when view disappears
data = await fetchData()
}
.refreshable {
// Pull-to-refresh with async support
data = await fetchData()
}
}
}
Handling Multiple Async Operations
struct DashboardView: View {
@State private var stats: Stats?
@State private var recentItems: [Item] = []
var body: some View {
VStack {
// Content
}
.task {
async let fetchedStats = fetchStats()
async let fetchedItems = fetchRecentItems()
// Parallel execution
(stats, recentItems) = await (fetchedStats, fetchedItems)
}
}
}
7. Error Handling
Centralized Error Handling
@MainActor
class ErrorHandler: ObservableObject {
@Published var currentError: AppError?
@Published var showError = false
func handle(_ error: Error) {
currentError = AppError(from: error)
showError = true
}
}
struct ContentView: View {
@StateObject private var errorHandler = ErrorHandler()
var body: some View {
MainContent()
.environmentObject(errorHandler)
.alert("Error", isPresented: $errorHandler.showError) {
Button("OK") { }
} message: {
Text(errorHandler.currentError?.message ?? "Unknown error")
}
}
}
8. Testing SwiftUI Views
ViewInspector for Unit Tests
import ViewInspector
func testUserRowDisplaysName() throws {
let user = User(id: UUID(), name: "John Doe", email: "john@example.com")
let view = UserRow(user: user)
let text = try view.inspect().hStack().text(0).string()
XCTAssertEqual(text, "John Doe")
}
func testViewModelFetchesUsers() async {
let mockService = MockUserService()
mockService.usersToReturn = [User(id: UUID(), name: "Test")]
let viewModel = UserViewModel(userService: mockService)
await viewModel.fetchUsers()
XCTAssertEqual(viewModel.users.count, 1)
XCTAssertEqual(viewModel.users.first?.name, "Test")
}
9. Accessibility
SwiftUI makes accessibility easier, but you still need to be intentional:
struct ProductCard: View {
let product: Product
var body: some View {
VStack {
AsyncImage(url: product.imageURL)
Text(product.name)
Text(product.price, format: .currency(code: "USD"))
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(product.name), \(product.price.formatted(.currency(code: "USD")))")
.accessibilityHint("Double tap to view details")
.accessibilityAddTraits(.isButton)
}
}
10. Project Organization
A well-organized project scales better:
Project/
├── App/
│ ├── AppDelegate.swift
│ └── MyApp.swift
├── Features/
│ ├── Authentication/
│ │ ├── Views/
│ │ ├── ViewModels/
│ │ └── Models/
│ ├── Home/
│ └── Profile/
├── Core/
│ ├── Network/
│ ├── Storage/
│ └── Extensions/
├── Components/
│ ├── Buttons/
│ ├── Cards/
│ └── Forms/
└── Resources/
├── Assets.xcassets
└── Localizable.strings
Need Help Building Your SwiftUI App?
With extensive experience building production SwiftUI apps, I can help architect and develop your iOS application using best practices.
Let's Build Together