Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 005 / 100

Structs vs classes in Swift in Swift, SwiftUI & iOS Apps

Value types vs reference types.

Expona Admin7 min read

Understanding the Fundamental Difference

The distinction between structs and classes in Swift sits at the heart of how you'll design your data models and architecture. Whether you choose a struct or a class determines whether your data is copied when passed around or shared across your application, and this single decision cascades through your entire codebase. In this article, we'll explore structs vs classes in Swift, SwiftUI & iOS Apps by examining when to reach for each tool and how they interact with SwiftUI's reactive paradigm.

At the most fundamental level, structs are value types and classes are reference types. When you pass a struct to a function or assign it to a variable, Swift copies it. When you pass a class instance, you're passing a reference to the same object in memory. This distinction may seem academic, but it has profound implications for memory management, performance, concurrency safety, and the predictability of your code.

Structs Are Copied; Classes Are Shared

Let's ground this in a concrete example. Imagine you're building a ride-hailing app and you need to represent a booking:

swift
struct BookingStruct {
    var id: String
    var passengerName: String
    var pickupLocation: String
    var status: String
}

class BookingClass {
    var id: String
    var passengerName: String
    var pickupLocation: String
    var status: String
    
    init(id: String, passengerName: String, pickupLocation: String, status: String) {
        self.id = id
        self.passengerName = passengerName
        self.pickupLocation = pickupLocation
        self.status = status
    }
}

var structBooking = BookingStruct(id: "123", passengerName: "Alice", pickupLocation: "Downtown", status: "Pending")
var structBookingCopy = structBooking
structBookingCopy.status = "Confirmed"

print(structBooking.status)  // "Pending"
print(structBookingCopy.status)  // "Confirmed"

When we assign structBooking to structBookingCopy, Swift creates a complete, independent copy. Modifying one doesn't touch the other. Now watch what happens with a class:

swift
var classBooking = BookingClass(id: "123", passengerName: "Alice", pickupLocation: "Downtown", status: "Pending")
var classBookingReference = classBooking
classBookingReference.status = "Confirmed"

print(classBooking.status)  // "Confirmed"
print(classBookingReference.status)  // "Confirmed"

Both variables point to the same object. When we modify classBookingReference, we're modifying the shared instance. This is because classes are reference types—they live on the heap and we pass around pointers to them.

The copy behavior of structs gives you predictability. You can reason about your data locally without worrying about distant parts of your code mutating it unexpectedly. However, if you actually need shared, mutable state—as you often do in iOS apps—repeatedly copying becomes inefficient and makes collaboration between components harder.

Prefer Structs for Models

In modern Swift development, especially with SwiftUI, we've moved toward structs as the default choice for data models. Your domain models—bookings, users, products, orders—should typically be structs.

Consider a travel app's user profile model:

swift
struct User {
    let id: UUID
    let email: String
    var firstName: String
    var lastName: String
    var preferences: UserPreferences
}

struct UserPreferences {
    var preferredCurrency: String
    var darkModeEnabled: Bool
    var notificationsEnabled: Bool
}

Using structs here brings several advantages. First, value semantics make data flow predictable. When you pass a user to a view or a networking layer, you know exactly what data is being operated on. Second, structs are thread-safe by default—if each thread has its own copy, there's no race condition. Third, testing becomes simpler because you can create and manipulate test fixtures without worrying about global state mutations.

Structs also work beautifully with Swift's immutability patterns. You can declare a struct property as let and rely on the compiler to enforce immutability across the entire object graph. Try doing that consistently with classes, and you'll quickly find yourself fighting the language.

For model structs, aim to make properties as immutable as possible. Use let for identity fields like IDs and timestamps. Use var only for fields that genuinely need to change after initialization. This discipline makes your data flow easier to reason about.

Classes for Shared Mutable State and Identity

Classes excel when you need true sharing and identity. If multiple parts of your app need to observe and mutate the same object, a class is appropriate.

A classic example is an observable view model or a connection manager. In a CRM application, imagine a customer detail view model that multiple views subscribe to:

swift
class CustomerDetailViewModel: ObservableObject {
    @Published var customer: Customer
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
    
    private let networkService: CustomerNetworkService
    private let analyticsService: AnalyticsService
    
    init(customer: Customer, networkService: CustomerNetworkService, analyticsService: AnalyticsService) {
        self.customer = customer
        self.networkService = networkService
        self.analyticsService = analyticsService
    }
    
    func updateCustomerName(_ newName: String) {
        customer.name = newName
        Task {
            do {
                try await networkService.updateCustomer(customer)
                analyticsService.log(event: "customer_updated")
            } catch {
                errorMessage = "Failed to update customer"
            }
        }
    }
}

Here we need a class because:

  • Multiple views need to observe the same instance and react to changes via @Published properties.
  • The view model maintains identity—it's "the" customer detail view model for this particular customer.
  • Mutable state (the customer, loading state, error) needs to be shared and updated in place.
  • The view model coordinates side effects like networking and analytics, which inherently involve shared resources.

If we used a struct here, every time we passed the view model to a view or updated its state, we'd create a copy. SwiftUI would struggle to track which copy is "the source of truth." The reactive binding system depends on reference semantics to function properly.

Identity matters in class semantics too. You can use reference equality to ask "is this the exact same object?" With structs, equality is structural—two structs with the same values are considered equal. Sometimes you need to distinguish "the current logged-in user" from "another user with identical data," and that requires a class.

SwiftUI Views Are Structs

SwiftUI views are always structs, and this is intentional. Views in SwiftUI are immutable descriptions of the UI, not mutable objects that mutate over time.

swift
struct CustomerListView: View {
    @StateObject private var viewModel = CustomerListViewModel()
    
    var body: some View {
        List(viewModel.customers) { customer in
            NavigationLink(destination: CustomerDetailView(customer: customer)) {
                VStack(alignment: .leading) {
                    Text(customer.name)
                        .font(.headline)
                    Text(customer.email)
                        .font(.caption)
                        .foregroundColor(.gray)
                }
            }
        }
        .navigationTitle("Customers")
        .onAppear {
            viewModel.loadCustomers()
        }
    }
}

The view struct itself is immutable. It doesn't hold mutable state directly. Instead, state is managed by @State, @StateObject, @EnvironmentObject, or injected view models. When state changes, SwiftUI creates a new view struct and re-renders it.

This architecture—immutable view descriptions backed by mutable state-holding objects—creates a clean separation of concerns. Views describe the UI. View models or state objects manage the data and side effects. The combination is more testable and maintainable than if views were mutable objects that updated themselves.

Never make custom view subclasses or mutable view structs. Stick to the struct-based composition approach, and use view models and environment objects to manage state.

Common Mistakes

Mistake 1: Using classes for everything because "that's what we did in UIKit." UIKit's view controller inheritance model pushed you toward classes. SwiftUI is different. Start with structs and only reach for classes when you need shared mutable state or observable behavior. Your code will be simpler and safer.

Mistake 2: Making view models structs. View models coordinate state and side effects. Multiple views need to observe the same instance. Use a class with ObservableObject and @Published. A struct view model will cause Xcode to complain about view identity and state binding.

Mistake 3: Copying data unnecessarily. If you're working with large datasets and you keep passing structs around, be aware that Swift copies them. In performance-critical code, consider whether a reference type makes sense, or use immutable data structures designed for structural sharing (like those in functional libraries).

Mistake 4: Mixing structs and classes carelessly in your data models. If a struct contains a class property, the struct is copied but the class reference is shared. This can lead to subtle bugs where you think you've isolated data but haven't. Be intentional: either keep everything structs or carefully model the ownership and sharing semantics.

Mistake 5: Ignoring thread safety implications. Structs are thread-safe by default because each thread gets its own copy. Classes require synchronization or immutability guarantees. Don't assume a class is safe to mutate from multiple threads without locks or actor isolation.

Key Takeaways

  • Structs are value types and are copied; classes are reference types and are shared.
  • Use structs by default for data models and domain objects. They're predictable, thread-safe, and play well with immutability.
  • Use classes for view models, coordinators, and anything that needs to be observed by multiple parts of your app or that manages long-lived shared resources.
  • SwiftUI views are structs backed by state-holding objects (view models or @StateObject). Never fight this architecture.
  • Understand the implications: structs are copied (safer, predictable, thread-safe but potentially slower for large objects), and classes are shared (flexible for shared state but require careful management of mutations and identity).
  • Think carefully about your data flow. If data flows through multiple layers and needs to stay in sync, consider a class. If each component receives its own snapshot, a struct is cleaner.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles