Stop using MVVM for SwiftUI

Don’t over-engineering! No suggested architecture for SwiftUI, just MVC without the C.

On SwiftUI you get extra (or wrong) work and complexity for no benefits. Don’t fight the system.

PART 1

I've been looking at learning more and implementing the Active Record pattern, and had some more questions + some gotchas that I noticed.

If I understand the pattern correctly, Active Record is used for capturing global state via statics, and locals via functions. This state isn't tied to a specific view or workflow.

  1. To hold local view state, we need to use a @State or @StateObject. For example, this could be the results of a Product.all call.
  2. If the call is async, we need to also load it in a .task modifier.
  3. We also need some way of subscribing to updates or otherwise refreshing the data. For example, we could update the @State or @StateObject after calling an update function.

The problems I ran into mostly revolved around 3 above. Using the same example as before where we have files on a file system and we want to mark some of them as favourites:

struct File: Identifiable, Equatable, Hashable {
    var id: UUID = UUID()
    var name: String
    var date: Date
    var size: Int64
}

struct Folder: Identifiable {
    var id: UUID = UUID()
    var name: String
    var files: [File]
}

If I use an observable object to hold all of the state, I end up with this:

class FilesStore: ObservableObject {
    var all: [File] {
        return folders.flatMap { $0.files }
    }

    @Published var favorites: Set<File> = []

    @Published var folders: [Folder] = [
        Folder(name: "Classes", files: [
            File(name: "File 5.txt", date: Date(timeIntervalSinceNow: -300000), size: 8234567),
            File(name: "File 6.txt", date: Date(timeIntervalSinceNow: -290000), size: 4890123),
            File(name: "File 7.txt", date: Date(timeIntervalSinceNow: -280000), size: 11234567),
        ]),
        Folder(name: "Notes", files: [])
    ]

    func isFavorite(_ file: File) -> Bool {
        return favorites.contains(file)
    }

    func toggleFavorite(_ file: File) {
        if (favorites.contains(file)) {
            favorites.remove(file)
        } else {
            favorites.insert(file)
        }
    }
}

If I use these @Published vars directly in the view, then everything just "works" because all updates via @Published vars are propagated directly so everything stays in sync.

Here are the corresponding views:

struct ContentView: View {
    @StateObject var filesStore = FilesStore()
    
    var body: some View {
        NavigationView {
            FolderListView()
            FileListView(folderName: "All Files", files: filesStore.all)
        }
        .environmentObject(filesStore)
    }
}

struct FolderListView: View {
    @EnvironmentObject var filesStore: FilesStore
    
    var body: some View {
        let favorites = filesStore.favorites
        
        List {
            Section {
                FolderListRow(folderName: "All Files", files: filesStore.all)
                if (!favorites.isEmpty) {
                    FolderListRow(folderName: "Favorites", files: Array(favorites))
                }
            }
            
            Section("My folders") {
                ForEach(filesStore.folders) { folder in
                    FolderListRow(folderName: folder.name, files: folder.files)
                }
            }
        }
        .navigationTitle("Folders")
        .listStyle(.insetGrouped)
    }
}

struct FolderListRow: View {
    let folderName: String
    let files: [File]
        
    var body: some View {
        NavigationLink(destination: FileListView(folderName: folderName, files: files)) {
            HStack {
                Text(folderName)
                Spacer()
                Text(files.count.formatted())
                    .foregroundStyle(.secondary)
            }
        }
    }
}

struct FileListView: View {
    @EnvironmentObject var filesStore: FilesStore
    
    let folderName: String
    let files: [File]
    
    var body: some View {
        List(files) { file in
            let isFavorite = filesStore.isFavorite(file)
            
            VStack() {
                HStack {
                    Text(file.name)
                    Spacer()
                    if isFavorite {
                        Image(systemName: "heart.fill")
                            .foregroundColor(.red)
                            .font(.caption2)
                    }
                }
            }
            .swipeActions(edge: .leading) {
                Button {
                    filesStore.toggleFavorite(file)
                } label: {
                    Image(systemName: isFavorite ? "heart.slash" : "heart")
                }
                .tint(isFavorite ? .gray : .red)
            }
        }
        .animation(.default, value: files)
        .listStyle(.plain)
        .navigationTitle(folderName)
    }
}

With the Active Record pattern, I remove FilesStore and reorganized the code as follows:

// Stores

class FilesystemStore {
    static var shared = FilesystemStore()
    
    var folders: [Folder] = [
        Folder(name: "Classes", files: [
            File(name: "File 5.txt", date: Date(timeIntervalSinceNow: -300000), size: 8234567),
            File(name: "File 6.txt", date: Date(timeIntervalSinceNow: -290000), size: 4890123),
            File(name: "File 7.txt", date: Date(timeIntervalSinceNow: -280000), size: 11234567),
        ]),
        Folder(name: "Notes", files: [])
    ]
}

class FavoritesStore {
    static var shared = FavoritesStore()
    
    var favorites: Set<File> = []
    
    func isFavorite(_ file: File) -> Bool {
        return favorites.contains(file)
    }
    
    func toggleFavorite(_ file: File) {
        if (favorites.contains(file)) {
            favorites.remove(file)
        } else {
            favorites.insert(file)
        }
    }
}

// Active record -- contents

extension Folder {
    static var all: [Folder] {
        return FilesystemStore.shared.folders
    }
}

extension File {
    static var all: [File] {
        return Folder.all.flatMap { $0.files }
    }
}

// Active record -- favorites

extension File {
    static var favorites: Set<File> {
        FavoritesStore.shared.favorites
    }
        
    static let favoriteUpdates = PassthroughSubject<Set<File>, Never>()
    
    func isFavorite() -> Bool {
        return FavoritesStore.shared.isFavorite(self)
    }
    
    func toggleFavorite() {
        FavoritesStore.shared.toggleFavorite(self)
        File.favoriteUpdates.send(File.favorites)
    }
}

The problem I ran into with this is that the view is now reaching directly into the model to do things like toggle if a file is a favorite or not. Because those properties are being set directly, we now need a way to update the view state to reflect the change. I handled that by using Combine to publish updates (I'm sure it's possible with AsyncStream too, like StoreKit 2 is doing, but I didn't figure out how to do this).

Continued in part 2 below...

PART 2

The second problem is that now the view also won't update unless I'm sure to also add a reference to the @StateObject to that view, so it knows that it needs to update. For example, when using the Active Record pattern, I can call file.isFavorite() instead of filesStore.isFavorite(file) to know if a file is a favorite or not. Because I'm calling the method directly on the file instead of going through the StateObject, I can bypass the @Published var and thus if I'm not careful, I'll miss updates since SwiftUI no longer knows to update this view.

Here is the view code when using Active Record:

struct ContentView: View {
    @StateObject var favoritesObject = MyFavoritesObject()
    var body: some View {
        NavigationView {
            FolderListView()
            FileListView(folderName: "All Files", files: File.all)
        }
        .environmentObject(favoritesObject)
    }
}

struct FolderListView: View {
    @EnvironmentObject var favoritesObject: MyFavoritesObject
    
    var body: some View {
        let favorites = favoritesObject.favorites
        
        List {
            Section {
                FolderListRow(folderName: "All Files", files: File.all)
                if (!favorites.isEmpty) {
                    FolderListRow(folderName: "Favorites", files: Array(favorites))
                }
            }
            
            Section("My folders") {
                ForEach(Folder.all) { folder in
                    FolderListRow(folderName: folder.name, files: folder.files)
                }
            }
        }
        .navigationTitle("Folders")
        .listStyle(.insetGrouped)
    }
}

struct FolderListRow: View {
    let folderName: String
    let files: [File]
        
    var body: some View {
        NavigationLink(destination: FileListView(folderName: folderName, files: files)) {
            HStack {
                Text(folderName)
                Spacer()
                Text(files.count.formatted())
                    .foregroundStyle(.secondary)
            }
        }
    }
}

struct FileListView: View {
    // Needed to get favorite updates
    @EnvironmentObject var favoritesObject: MyFavoritesObject
    
    let folderName: String
    let files: [File]
    
    var body: some View {
        List(files) { file in
            let isFavorite = file.isFavorite()
            
            VStack() {
                HStack {
                    Text(file.name)
                    Spacer()
                    if isFavorite {
                        Image(systemName: "heart.fill")
                            .foregroundColor(.red)
                            .font(.caption2)
                    }
                }
            }
            .swipeActions(edge: .leading) {
                Button {
                    file.toggleFavorite()
                } label: {
                    Image(systemName: isFavorite ? "heart.slash" : "heart")
                }
                .tint(isFavorite ? .gray : .red)
            }
        }
        .animation(.default, value: files)
        .listStyle(.plain)
        .navigationTitle(folderName)
    }
}

So the differences are basically that we can call methods like File.all or file.toggleFavorite() instead of passing through FilesStore. The problem is with the flow of data as mentioned above.

Even with the Combine mechanism, I also have to be sure that the view has a reference to the state object via @EnvironmentObject, otherwise it still won't update since it's reading file.isFavorite() directly.

Of course this example is somewhat contrived, and even when using the state objects, "real" production code would have to query the file system async and then update the published vars. The difference is that I can handle that internally and update the @Published vars as needed, while with Active Record, I need to be sure to have some mechanism to propagate those updates so that the view is still updated, with Combine or a similar mechanism.

Another alternative would be to call a load() method manually on the view or state object every time a mutating function is called, but that seems cumbersome.

I'm most likely missing something or not fully understanding the patterns, so hoping that you or someone else can illuminate this further. It just looks like from my POV, you still need an intermediate StateObject when using Active Record. I also find the code easier to understand and reason about when doing everything through a StateObject instead of also having functions and static collection vars.

This was a fun exercise to go through and I definitely learned something. Hoping to continue the discussions. :)

For an Application you can use active record for that. This works great for a lib, data access, data model.

struct File: Identifiable, Equatable, Hashable {
    var id: UUID = UUID()
    var name: String
    var date: Date
    var size: Int64
    var isFavourite: Bool // change attribute or reference on set / didSet

    func save()
    func delete()

    static var allFiles: [File] { … }
    static var onlyFavouriteFiles: [File] { … }
}

struct Folder: Identifiable {
    var id: UUID = UUID()
    var name: String
    var files: [File] // or computed property that fetch (on demand) files from this folder

    static var folders: [Folder] { … }
}

But in SwiftUI (declarative view layer) you can also need a state(s). You can have a FileStore, FolderStore, FileManagement, … that is part of your model. Assuming that we use system FileManager and you load the items sync when needed.

class FileManagement: ObservableObject {
    var folders: [Folder] = Folder.folders

    func createFile(…) { … } // do changes, call objectWillChange.send()
    func deleteFile(…) { … } // do changes, call objectWillChange.send()
    func setFileAsFavourite(…) { … } // do changes, call objectWillChange.send()
}
struct MyApp: App {
    @StateObject var fileManagement = FileManagement()

    @State var selectedFolder: Folder? = nil
    @State var selectedFile: File? = nil

    var body: some Scene {
        WindowGroup {
            // Three-column
            NavigationSplitView {
                FolderList($selectedFolder)
            } content: {
                if let folder = selectedFolder {
                    FileList($selectedFile, files: folder.files) // or FileList($selectedFile, folder: folder)
                } else {
                    Text(“Select a folder”)
                }
            } detail: {
                if let file = selectedFile {
                    FileView(file: file) // details, attributes, preview, …
                } else {
                    Text(“Select a file”)
                }
            }
            .environmentObject(fileManagement)
        }
    }
}

Note: For changes use FileManagment methods, not File / Folder methods (FileManagement will call them). Also you can just do the operations in FileManagement and remove File / Folder methods.

// Do the operations in disk
class FileManagement: ObservableObject {
    var folders: [Folder] = Folder.folders

    func createFile(…) { … } // do changes, call objectWillChange.send()
    func deleteFile(…) { … } // do changes, call objectWillChange.send()
    func setFileAsFavourite(…) { … } // do changes, call objectWillChange.send()
}
// Do the operations in memory
class FileManagement: ObservableObject { // or class FolderStore: ObservableObject {
    @Published var folders: [Folder] = []

    func loadFolders() { folders = Folder.folders } // or func load() { folders = Folder.folders }

    func createFile(…) { … } // change the folders property hierarchy
    func deleteFile(…) { … } // change the folders property hierarchy
    func setFileAsFavourite(…) { … } // change the folders property hierarchy
}

Also you can just use only the File and Folder active record with a FileWatcher (notify file system changes):

struct File: Identifiable, Equatable, Hashable {
    var id: URL { url }
    var url: URL
    var name: String
    var date: Date
    var size: Int64
    var isFavourite: Bool // change attribute or reference on set / didSet

    func save()
    func delete()

    static var allFiles: [File] { … }
    static var onlyFavouriteFiles: [File] { … }
}

struct Folder: Identifiable {
    var id: URL { url }
    var url: URL
    var name: String
    var files: [File] // or computed property that fetch (on demand) files from this folder

    static var folders: [Folder] { … }
}
struct MyApp: App {
    @StateObject var fileWatcher = FileWatcher() // Notify file system changes

    @State var selectedFolder: Folder? = nil
    @State var selectedFile: File? = nil

    var body: some Scene {
        WindowGroup {
            // Three-column
            NavigationSplitView {
                FolderList($selectedFolder, folders: Folder.folders)
            } content: {
                if let folder = selectedFolder {
                    FileList($selectedFile, files: folder.files) // or FileList($selectedFile, folder: folder)
                } else {
                    Text(“Select a folder”)
                }
            } detail: {
                if let file = selectedFile {
                    FileView(file: file) // details, attributes, preview, …
                } else {
                    Text(“Select a file”)
                }
            }
        }
    }
}

Also, you can use FileManagement as the only SSOT. Note: File and Folder structs can be simple structs, not an active records, you do all the things in FileManagement.

class FileManagement: ObservableObject {
    var folders: [Folder] { Folder.folders }
    @Published var selectedFolder: Folder? = nil

    var files: [File] { selectedFolder?.files ?? [] }
    @Published var selectedFile: File? = nil

    func startMonitoringChanges() // call objectWillChange.send() on changes
    func stopMonitoringChanges()

    // Folder and file operations here if needed / wanted
}
struct MyApp: App {
    @StateObject var fileManagement = FileManagement()

    var body: some Scene {
        WindowGroup {
            // Three-column
            NavigationSplitView {
                FolderList()
            } content: {
                FileList()
            } detail: {
                FileView() // details, attributes, preview, …
            }
            .environmentObject(fileManagement)
            .onAppear(perform: fileManagement.startMonitoringChanges)
        }
    }
}

Remember: Don’t worry about “reloading” folders and files, SwiftUI will check the differences and only update what changes. This is the reason why we should use Identifiable protocol.

Demistify SwiftUI - WWDC 2021 Session

Thanks very much for these posts. I'll collect all my questions here since the comments are limited:

  1. In your first example, would it be better to not use Active Record, or if we do, to make sure mutations happen through the StateObject and not by calling functions directly on the active record?
  2. In the second example, When you say call objectWillChange.send(), is this replacing @Published? I.e: We call objectWillChange.send(), then SwiftUI will redraw the view, and automatically get the new contents when it re-reads Folder.folders?
  3. In the second part of that same post, we're depending on the FileWatcher to invalidate the view right? I.e. FileWatcher should be sending an objectWillChange.send(), so we get all the correct values inside the view body when all of the properties are re-queried?
  4. If we're not planning on re-using components but are developing everything in the same app via SwiftUI, does Active Record have any advantages, or is it better to handle everything through StateObjects? Does Active Record make more sense when developing a "general" component outside of the SwiftUI / combine framework then? I.e. like how StoreKit2 was designed?
  5. Finally, when implementing Active Record, since we're using static vars, does it make sense to call into singletons the way I did it, via the shared static vars? Or otherwise how is this generally done? How does the Active Record actually access the objects that it needs to via those static vars? If you want to swap implementations, for testing purposes or maybe because you actually need different implementations at runtime, how is this usually handled?

SwiftUI is really interesting and I'm finding it much easier to develop out new features. Learning about these patterns is super helpful, so thanks for taking the time to engage!

1 - Active Record (like Data Mapper or Repository) is a pattern, very popular, for data access, we use where needed, I use some example to show that model is not only property’s structs. For a library you can use Product, Order as active record but for an app you should include a ProductStore (state), OrderStore (state)… also you can implement all Product, Order tasks on Store and not use Product, Order as active record at all. Remember: typically a framework, like StoreKit 2, need to be flexible, more stateless. A framework is used by an app. For an app like SwiftUI you need one or more state objects, stateful.

2 - Yes, objectWillChange.send() do the @Published job, also @Published is a convenience for objectWillChange.send() and use it. See 2019 post about this, initially (SwiftUI betas)we don’t have @Published. Forget in last posts, you should call objectWillChange.send() before you change the property, not after, be careful with asyncs stuffs.

@Published var selectedFolder: Folder? = nil

=

var selectedFolder: Folder? = nil {
    willSet {
        objectWillChange.send()
    }
}

3 - Yes!

4 - As I said in 1 point, you can handle everything on ObservableObject and keep only property structs, and just add some needed tasks to structs.

struct Product {
    // Properties (data)

    // Tasks
    func purchase() async throws { ... }

    // Factory Method
    static products: [Self] {
        get async throws {
           return try await WebService.shared.request(…)
        }
    }
}

class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    func load() async { 
        do {
            products = try await Product.products
        } catch {
            // handle error
        }
    }
}

or

struct Product {
    // Properties (data)

    // Tasks
    func purchase() async throws { ... }
}

class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    func load() async { 
        do {
            products = try await WebService.shared.request(…)
        } catch {
            // handle error
        }
    }
}

or

struct Product {
    // Properties (data)
}

class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    func purchase(_ product: Product) async throws { ... }

    func load() async { 
        do {
            products = try await WebService.shared.request(…)
        } catch {
            // handle error
        }
    }
}

5 - In active record you use static func / vars for “Factory Method”, not single instance. For this pattern you only use shared (single instance) for your service / provider objects (e.g. WebService.shared.request(…)). And in general for SwiftUI you should avoid singleinstance for non service / provider objects, use @EnvironmentObject.

ObservableObject is a working object where we aggregate related data and tasks.

We see ObservableObject as:

  • Model object
  • Business object
  • State, life-cycle, management object
  • User case or related use cases object

ObservableObject (working, in-memory data) from:

  • Computed
  • Disk (local)
  • Database (local)
  • Network (remote)
  • System Services

Example: My last app (multiplatform) have in-app purchases (use StoreKit 2), I have a SubscriptionStore (ObservableObject) for:

  • Load my web service features (multiplatform)
  • Load StoreKit products from features (call Product.products(featureIDs))
  • refreshPurchasedProducts (handle Transaction.currentEntitlements)
  • check feature availability based on StoreKit purchase / or my web service information (multiplatform)

I can add purchase task to SubscriptionStore but use product.purchase() from StoreKit 2 in View. As I use this object in different views I use @EnvironmentObject to have one instance and access from any view in hierarchy.

The app use 2 data access models based on Active Record, the my data (web service) model (part active record, part handle some things in the stores) and the StoreKit 2 model.

Remember: Active Record is about data access, not state. SwiftUI views need a state, local (@State) and external (@StateObject).

Imagine the CoreLocation 2 using await / async, no delegates. Now we use CLLocation (property only struct) and CLLocationManager (object). In future we could use Location as Active Record:

  • try await Location.current (gives current user location)
  • for await location in Location.updates (gives every locations changes, async sequence)

Also, how the long wait SwiftData (Core Data next generation) be like:

We have Active Record for data access in PHP Laravel database and Swift server-side Vapor (Fluent database).

Why doesn't Apple just come out and say if they designed SwiftUI to use MVVM or MV?

Easy, the MVVM comes from old technology. Also Microsoft, who sell MVVM from 2005 is not using it on new UI framework (declarative). SwiftUI is new tech, modern, declarative. They eliminate the middle layer. Is part of evolution, simplicity. We only need a View layer and a Model layer. Inside Model layer we can do what we want (also some VMs I see on blogs and tutorials are state / store, part of the model). We can’t use MVVM for SwiftUI without problems and limitations.

Talking and wanting MVVM or MVC today is the same wanting C++ or Pascal. No one want go back! There’s a modern and more easy techs.

The evolution:

  • C -> C++ -> Java / C# -> Swift
  • MVC -> MVVM -> MV

SwiftUI automatically performs most of the work traditionally done by view controllers. Fact: SwiftUI View is a ViewModel.

To remember! The model layer are not (and never was) only property structs. The model layer is our data, services / networking, state, business objects, processors, …

Many devs don’t understand the MVC / MVVM. Fact: VM (reactive) = C (imperative)

It's obvious - with SwiftUI we should drop the C from MVC, but still we should keep the business logic somewhere.

Let's try to reason where is the best place? (We are developers working on a moderate iOS project - not a small one, but not something huge.)

  • in the view? - No. (Too many reasons to avoid it - complex views, unreadable views, hard to unit test, etc.)

  • in the model - Probably.

  • in the controller - No. There is no controller in SwiftUI.

  • Otherwise we need some place which we can call whatever we want. (Some people prefer to use "ViewModel". Please, don't mess this with MVVM design pattern.).

Another generic problem that we should solve - caching (because we fetch some data from a server and we store it on the device, while the app is running, or even persist it between different sessions.) Also this data may be updated from time to time based on other users (imagine a chat functionality in an app).

Who should be responsible for storing that data? It's not the view. It's not the controller. Guess, who? - The model.

Well, if we continue the same way, the model is not anymore a typical model (closer to POJO) - it's growing and getting wiser and powerful (and massive :D). Which leads to some sort of separation if we want to make our codebase maintainable.

In summary - if the project you are working is growing then you have to introduce something that will make it easier to grow. It's up to you to pick the right tools.

MVVM or something similar might be an overkill in the beginning of any project. But at some point partially using any architectural Design Pattern (or just the good bits) will solve a lot of problems.

Another generic problem that we should solve - caching (because we fetch some data from a server and we store it on the device, while the app is running, or even persist it between different sessions.) Also this data may be updated from time to time based on other users (imagine a chat functionality in an app). Who should be responsible for storing that data? It's not the view. It's not the controller. Guess, who? - The model.

Yes 🙌

Finished an app with caching (local data sync from remote), our model have few “stores” as source of truth to do that job, and again EnvironmentObject is our best friend.

Another Example

We can have a single store for everything (not recommended for big, multi section / tab apps) or multi stores, separated by use case / data type / section / tab. Again, stores (ObservableObjects), we can call other names, are part of the model (yes we can separate from our data model). Also we can do everything in stores and not use active record pattern at all but my last experience tell me that is good to separate our data model (using a data access pattern) from state (we can call store object, business object, use case object, state object, external source of truth object, …).

This is the SwiftUI (declarative UI) approach. With MVVM pattern:

  • Requires middle layer and more code
  • Need one ViewModel for each View
  • Problems with shared state (e.g. EnvironmentObject)
  • Problems with local state (e.g. FocusState, GestureState, …)
  • Overall platform conflicts
  • Overall external data management limitations
  • Duplicating… SwiftUI View is a “ViewModel”
  • Massive ViewModels (yes can happen)

Who needs a ViewModel today?

Little, simple and clean code. Everything works great on iOS, iPadOS, macOS, tvOS, Unit / Integration Tests server. Very productive team with well defined responsabilities. Note: Just an example based on real SwiftUI app.

Stop using MVVM for SwiftUI
 
 
Q