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.

3/N Flutter uses bloc, provider etc. But the main idea is the same. All these frameworks works using the uni-directional flow. So, it would be worth it to use EnvironmentObject and just allow EnvironmentObject to be available to all the views. One complain about EnvironmentObject is that it will refresh a lot of views when any value changes. In those cases you can slice up the environment object (https://azamsharp.com/2022/07/01/slicing-environment-object.html).

4/N Although you can use Redux with SwiftUI but Redux have a lot of moving parts. You can simply create a fetchService, which loads the data and make sure to put that fetchService in environmentObject. This allows it to create one single source of truth. Another comment about Model objects. Most of the time, your app in consuming an API. The data you get from the API is not the domain model, that is the DTO (Data Transfer Object). Your domain object lies on the server, most of the time.

This message is from the original poster of this post. If you get this message please contact me on Twitter at @azamsharp. I am writing a post about the same topic and your help will be really appreciated.

Isn't this example of MV fine until you start expanding, for example if you wanted to select a Product in the List and access this state from multiple views, you could put this state on the Product object or would you put the Product you selected on a state object?

Again, you don’t need a middle layer, just your model! Everything becomes easy, composable and flexible. And yes! You can do Unit / Integration tests.

UI = f(State) => View = f(Model)

SignIn

// State (shared in view hierarchy), part of your model
class Account: ObservableObject {
    @Published var isLogged: Bool = false

    func signIn(email: String, password: String) async throws { ... }
}

// View
struct SignInView: View {
    @EnvironmentObject private var account: Account

    @State private var email: String
    @State private var password: String

    // Focus, error state

    var body: some View { ... }

    // Call from button
    func signIn() {
        // Validation (can be on model side)
        // Change error state if needed

        Task {
            do {
                try await account.signIn(email: email,
                                         password: password)
            } catch {
                // Change error state
            }
        }
    }
}

SignUp

// Data (Active Record), part of model, can be an ObservableObject if needed
struct Registration: Codable {
    var name: String = ""
    var email: String = ""
    var password: String = ""
    var carBrand: String? = nil
    
    func signUp() async throws { ... }
}

// State (Store), part of model
class CarBrandStore: ObservableObject {
    @Published var brands: [String] = []
    
    // Phase (loading, loaded, empty, error)

    func load() async { ... }
}

// View
struct SignUpView: View {
    @State private var registration = Registration()
    @StateObject private var carBrandStore = CarBrandStore()
    
    // Focus, error state
    
    var body: some View { ... }

    // Call from button
    func signUp() {
        // Validation (can be on model side)
        // Change error state if needed

        Task {
            do {
                try await registration.signUp()
            } catch {
                // Change error state
            }
        }
    }
}

Yes it's fine for small indie apps. Putting logic in a Model is absolutely ridiculous to me having Observed and State Objects provided by Apple and simply ignoring them because you don't like ViewModel term is just crazy in my opinion. Whatever works for you mate, I like my code modular and testable.

ok so If I am getting correctly then Account will have code for validation, network call, caching, routing, etc...

// State (shared in view hierarchy), part of your model
class Account: ObservableObject {
    @Published var isLogged: Bool = false

    func signIn(email: String, password: String) async throws {
     // VALIDATION CODE ****1
     // NETWORK CALL (we will have shared network object) ****2
     // PERSISTENT (we will have shared persistent object) ****3
     // ROUTE TO NEXT SCREEN (we will have global navigator or screen router) ****4
}
}

What we are achieving by giving so much responsibility to Account model.

Instead can't we just go with simple with SignInViewModel, also Validation, Persistency and Routing will not be tight together in Account.

See what I am thinking, and let's justify which one is better.

class SignInViewModel {
    func signIn(email: String, password: String) async throws {
     // NETWORK CALL (we will have shared network object) ****2
}
}

// View
struct SignInView: View {
    @EnvironmentObject private var account: Account

    @State private var email: String
    @State private var password: String

    var body: some View { ... }

    // Call from button
    func signIn() {

        // WE CAN CALL VALIDATION UTILITY FROM HERE ****1

        Task {
            do {
                try await signInViewModel.signIn(email: email, password: password)

              // WE CAN CALL PERSISTENT UTILITY FROM HERE ****3

             // THEN ROUTER CAN ROUTE TO NEXT VIEW  ****4

            } catch {
                // Change error state
            }
        }
    }
}

Model layer

class MyWebService {
    static let shared = MyWebService()
    
    // URLSession instance / configuration
    // Environments (dev, qa, prod, test / mocks)
    // Requests and token management
    
    var token: String? = nil
    
    func request<T>(/* path, method, query, payload*/) async throws -> T { ... }
    
    func requestAccess(username: String, password: String) async throws { ... }
    func revokeAccess() async throws { ... }
}
// Other names: User, UserStore, CurrentUser, ...
class Account: ObservableObject {
    @Published var isLogged: Bool = false
    @Published var error: Error? = nil
    
    struct Profile {
        let name: String
        let avatar: URL?
    }
    
    @Published var profile: Profile? = nil
    
    enum SignInError: Error {
        case mandatoryEmail
        case invalidEmail
        case mandatoryPassword
        case userNotFound
        case userActivationNeeded
    }
    
    func signIn(email: String, password: String) async {
        // Validation
        if email.isEmpty {
            error = SignInError.mandatoryEmail
            return
        } else if email.isEmail {
            error = SignInError.invalidEmail
            return
        }
        
        if password.isEmpty {
            error = SignInError.mandatoryPassword
            return
        }
        
        // Submit
        do {
            try await MyWebService.shared.requestAccess(username: email,
                                                        password: password)
            isLogged = true
            error = nil
        } catch HTTPError.forbidden {
            error = SignInError.userActivationNeeded
        } catch HTTPError.notFound {
            error = SignInError.userNotFound
        } catch {
            self.error = error
        }
    }
    
    func signOut() async {
        // Ignore any error
        try? await MyWebService.shared.revokeAccess()
        isLogged = false
    }
    
    func loadProfile() async {
        do {
            profile = try await MyWebService.shared.request(...)
            error = nil
        } catch {
            self.error = error
        }
    }
}

View layer

@main
struct MyApp: App {
    @StateObject var account = Account()
    
    var body: some Scene {
        WindowGroup {
            Group {
                if account.isLogged {
                    TabView { ... }
                        .task {
                            async account.loadProfile()
                        }
                } else {
                    SignInView()
                }
            }
            .environmentObject(account)
        }
    }
}
struct SignInView: View {
    @EnvironmentObject var account: Account
    
    @State private var email: String = ""
    @State private var password: String = ""
    
    // Text fields, button, error exception
    var body: some View {
        ScrollView { ... }
    }
    
    func signIn() {
        Task {
            await account.signIn(email: email,
                                 password: password)
        }
    }
}

How some developers, obsessed by testability & SOLID principles, think “separation of concerns”, making something simple, ..complex, problematic, expensive.

Model layer

class MyWebService {
    static let shared = MyWebService
    
    // URLSession instance / configuration
    // JSONDecoder/Encoder configuration
    // Environments (dev, qa, prod, test / mocks)
    // Requests and token management
    
    var token: String? = nil
    
    func request<T: Codable>(/* path, method, query, payload*/) async throws -> T { ... }
    
    func requestAccess(username: String, password: String) async throws { ... }
    func revokeAccess() async throws { ... }
}
struct Product: Identifiable, Hashable, Codable {
    let id: Int
    let name: String
    let description: String
    let image: URL?
    let price: Double
    let inStock: Bool
}
class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    enum Phase {
        case waiting
        case success
        case failure(Error)
    }
    
    @Published var phase: Phase? = nil
    
    func load() async {
        do {
            phase = .waiting
            products = try await MyWebService.shared.request(path: “products”)
            phase = .success
        } catch {
            phase = .failure(error)
        }
    }
}

View layer

struct ProductList: View {
    @StateObject var store = ProductStore()

    var body: some View {
        List { ... }
            .task {
                await store.load()
            }
            .navigationDestination(for: Product.self) { product in
                ProductView(product: product)
                // (--- OPTIONAL ---)
                // Only if you need to make changes to the store and that store its not already shared
                // ProductView(product: product)
                //    .environmentObject(store)
                // (--- OR ---)
                // ProductView(product: product, store: store)
            }
    }
}
struct ProductView: View {
    var product: Product
    // (--- OR ---)
    // If you make changes to the product
    // @State var product: Product    

    // (--- OPTIONAL ---)
    // Only if you need to make changes to the store and that store its not already shared
    // @EnvironmentObject private var store: ProductStore
    // (--- OR ---)
    // @ObservedObject var store: ProductStore
    
    var body: some View {
        ScrollView { ... }
    }
}

Let me try to explain what is over engineering looks like for a following problem statement: Problem Statement: Print "Hello World" 5 times.

// Technique 1 :  Can be written by a naive developer, but that is fine, code is functional
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")

// Technique 2 :  Better, here developer followed DRY principle ie Don't repeat yourself
for (i=1;i<=5;i=i+1) {
    print("Hello World")
}

// Technique 3 : Much better, along with DRY developer kept in mind for new changes that should be done easily
#define SIZE 5 
for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 4 : over engineering started, well in this case developer don't need to recompile the code when changes are there for SIZE, just need to update number in the config file

int SIZE = ReadSizeFromAConfigurationFile("config")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 5 : too much over engineering, now developer is reading that SIZE from the backend, again this has great flexibility, but you can see too many things have been involved here  

int SIZE = ReadSizeFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 6 : again too much over engineering, and developer is not following YAGNI principle, that's means requirement was saying print "Hello World", but developer written in a way that he/she can also change the string without any recompilation

int SIZE = ReadSizeFromTheBackend("some_server_url")
String s = ReadStringFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("\(s)")
}

For naive developers technique 6 can be over engineered... but for someone else it can be functional + maintainable code, also there is no standard definition of over engineering code...

Let me try to explain what is over engineering looks like for a following problem statement: Problem Statement: Print "Hello World" 5 times.

// Technique 1 :  Can be written by a naive developer, but that is fine, code is functional
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")

// Technique 2 :  Better, here developer followed DRY principle ie Don't repeat yourself
for (i=1;i<=5;i=i+1) {
    print("Hello World")
}

// Technique 3 : Much better, along with DRY developer kept in mind for new changes that should be done easily
#define SIZE 5 
for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 4 : over engineering started, well in this case developer don't need to recompile the code when changes are there for SIZE, just need to update number in the config file

int SIZE = ReadSizeFromAConfigurationFile("config")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 5 : too much over engineering, now developer is reading that SIZE from the backend, again this has great flexibility, but you can see too many things have been involved here  

int SIZE = ReadSizeFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 6 : again too much over engineering, and developer is not following YAGNI principle, that's means requirement was saying print "Hello World", but developer written in a way that he/she can also change the string without any recompilation

int SIZE = ReadSizeFromTheBackend("some_server_url")
String s = ReadStringFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("\(s)")
}

For naive developers technique 6 can be over engineered... but for someone else it can be functional + maintainable code, also there is no standard definition of over engineering code...

KISS (Keep It Simple) vs SOLID in numbers

(real world project work hours from my team)

Team KISS

  • Development: 120 hours
  • Change #1: 16 hours
  • Change #2: 2 hours

Team SOLID

  • Development: +1500 hours
  • Change #1: 48 hours
  • Change #2: 14 hours

Everything becomes problematic, complex, unmanageable and expensive. (no other team members understand and want to fix / change)

These results are REAL and the company / managers are upset about the SOLID decision. This is very common in many teams and companies. KISS approach is more productive and easy to test, fix, change.

This is really nice that Team KISS did the project just in 120hrs (15 working days) and Team SOLID took 1500hrs ie around 6 months

  1. S:Single responsibility
  2. O:Open closed
  3. L:Liskov substitution
  4. I:Interface segregation
  5. D:Dependency Inversion

I am just wondering which part took that much time, well they must be using S for writing isolated component or modules, also O for adding new features, not sure about L and I, but D also must be there for inverting the dependency. Can you please elaborate?

Sorry but my personal opinion is only about developers must stop follow SOLID and “Uncle Bob” bad practices. The fact is, in world, SOLID projects (small, big) become complex, expensive and unmanageable, this is a fact, there are results / metrics that prove it.

You can see the difference on Apple, after Scott Forstall left everything becomes problematic, buggy and delayed. Scott Forstall was not an Agile / CI / CD / SOLID guy and everything platform (and iOS software) release was perfect! Just works! Every developer and iPhone user see the difference. There’s an iOS before Scott and another after. This is a fact!

Also, many don’t know but Agile / “Uncle Bod” approach just killed Microsoft. This is very well known during Windows Longhorn (Vista) development, everything becomes problematic, delayed and we never see some great features. The problems stills next years and they lose the smartphone battle. This is a fact!

Note: I think (and hope!) that Apple is not a fully (or obsessed) Agile / CI / CD / SOLID company, but new developer generation could bringing it on.

I love this perspective on MV and SwiftUI. I need more time to digest it to understand how I would fully integrate it with my own apps (all SwiftUI + MVVM). I'm struggling to see how you would recommend incorporating Core Data into an MV-based app using SwiftUI's features like @FetchedRequest. It seems like @FetchedRequest specifically puts the model directly in the view, leading to code bloat in the View. This is further complicated by Core Data requiring developers to use a Class (i.e., the NSManagedObject subclass entity) as the fetched object. Any thoughts? Thanks!

but if you have an app with many users, not all of them are on iOS 15. So you cant use task, async and await on i0S 14!

Thanks for the thread. As a newcomer to SwiftUI, though, I'm not sure how this would actually map in practice. For example, if I had a setup like this:

struct FileItem {
    var name: String
    var size: Int64

    static var all: [FileItem] = { ... }
    static var favourites: [FileItem] = { ... }

    func setAsFavourite(isFavourite: Bool) { ... }
}

It's the part that goes in those "..." that I'm having some trouble figuring out. I have experience with Android and I know how I'd go about it there: I'd have a Repository which queries the file system (and potentially caches the latest data), a view model which holds view-specific state (for example, maybe just the file items in a specific folder) and handles any sorting, filtering etc..., and a view which asks the view model to refresh the data on certain events, such as onResume. If async is needed, I'd just launch that in a viewModelScope inside the view model.

I'm not sure how to map this to SwiftUI with the model described in this post.

For example, if I did:

struct MyApp: App {
    @State var allItems = FileItem.all
    @State var favouriteItems = FileItem.favourites

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(allItems) 
                MyListView(favouriteItems) 
            }
        }
    }
}
struct MyListView {
    @Binding var items: [FileItem]

    // Imagine there's a list here with buttons, and I can toggle the favourite status by tapping on the button for that item.

So what exactly should be happening if I call FileItem.setAsFavourite? Should it be adding a copy of the struct to the favourites array?

Would it make more sense to have things like "isFavourite" be instance vars, and then a collection like "favourites" would be a computed property? If so, would favourites still be a @State or otherwise how would the second list know that the value of the computed property changed? Would @State var favouriteItems = FileItem.favourites still work in that case?

How could collection updates be propagated? Would that look something like this:


async func syncCollectionWithFs() {
  // ... query FS
   var collection = query()
   MainActor.run { 
        FileItem.all = collection 
        FileItem.favourites =// Assume I'd map against some favourites DB to see what was marked as a favourite.
    }
}

But then how does this actually get propagated to the @States? By assigning the contents as a @State, didn't I already make a copy and updating the struct static vars isn't necessarily going to be published? Is this where ObservableObject would make more sense? I'm curious what the use case of the static funcs & vars on the structs are and how they are intended to be used.

How would this change if we make the collections async sequences or the functions async?

I think I'd know how I'd solve these questions if I was doing something more similar to Android with ViewModels and ObservableObjects, so these might seem like really basic questions, but as I said I'm coming from another universe, and I'm curious. ;) Thank you.

Hi, first if you are making a framework / library (stateless in many cases) your FileItem is perfect.

Active Record Pattern

struct FileItem {
    var name: String
    var size: Int64

    static var all: [FileItem] = { ... }
    static var favourites: [FileItem] = { ... }

    func setAsFavourite(isFavourite: Bool) { ... }
}

Repository Pattern

struct FileItem {
    var name: String
    var size: Int64
}

class FileItemRepository {
    func getAll() -> [FileItem] { ... }
    func getFavourites() -> [FileItem] { ... }

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}

If you are making an App (stateful) you need a state. Think about single source of truth(s) and use ObservableObject for external state, you can have one or many state objects. All depends on your app needs but Keep It Simple. You can keep your FileItem with tasks or not, depends.

Example #1 (assuming favorite is a file attribute or a reference)

struct FileItem {
    var name: String
    var size: Int64

    func setAsFavourite(isFavourite: Bool) { ... }
}
class FileStore: ObservableObject {
    @Published var all: [FileItem] = []
    var favourites: [FileItem] { … } // filter favourites from all

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files, manage states (loading, error) if needed
}
struct MyApp: App {
    @StateObject var store = FileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(.all)
                MyListView(.favourites)
            }
            .environmentObject(store)
            .task { await store.load() }
        }
    }
}

Example #2.1 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var all: [FileItem] = []
    @Published var favourites: [FileItem] = []

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files and favourites files, manage states (loading, error) if needed

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
struct MyApp: App {
    @StateObject var store = FileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(.all)
                MyListView(.favourites)
            }
            .environmentObject(store)
            .task { await store.load() }
        }
    }
}

Example #2.2 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var files: [FileItem] = []
    
    enum Source {
        case all
        case favourites
    }

    var source: Source

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files or favourites files, manage states (loading, error) if needed

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(FileStore(.all))
                MyListView(FileStore(.favourites))
            }
        }
    }
}

…or…

struct MyApp: App {
    @StateObject var allFileStore = FileStore(.all)
    @StateObject var favouriteFileStore = FileStore(.favourites)

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView()
                    .environmentObject(allFileStore)
                MyListView()
                    .environmentObject(favouriteFileStore)
            }
        }
    }
}

Example #2.3 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var files: [FileItem] = []

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    open func load() async { … } // to subclass

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
class AllFileStore: FileStore {
    open func load() async { … } // load all files, manage states (loading, error) if needed
}
class FavouritesFileStore: FileStore {
    open func load() async { … } // load favourites files, manage states (loading, error) if needed
}
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(AllFileStore())
                MyListView(FavouriteFileStore())
            }
        }
    }
}

…or…

struct MyApp: App {
    @StateObject var allFileStore = AllFileStore()
    @StateObject var favouriteFileStore = FavouriteFileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView()
                    .environmentObject(allFileStore)
                MyListView()
                    .environmentObject(favouriteFileStore)
            }
        }
    }
}

Tips:

  • Don’t think ViewModel, think state (view independent) that is part of your model
  • You can have one or many ObservableObjects (external state)
  • Use ObservableObjects when needed and don’t forget view local state
  • EnvironmentObject is your best friend!
  • Keep It Simple

In my 3 professional / client SwiftUI apps I made, I learned that EnvironmentObject is critical for many situations. Also, many things become problematic (or impossible) if I think about ViewModels.

thanks for sharing this! Lately I was also starting to notice that MVVM is kinda forced into SwiftUI and ends up making a lot of boilerplate

anyway I need time to digest all of this since I’m a huge advocate of MVVM in UIKit

in the meantime can I ask you the link for the Apple slides? (I ve probably missed this session)

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

Stop using MVVM for SwiftUI
 
 
Q