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.

Post not yet marked as solved Up vote post of Appeloper Down vote post of Appeloper
84k views
  • Thank you, very helpful. Especially enjoyed the big fat mess as the end 😂

  • 1/N Thanks for writing this post! I have written many articles regarding SwiftUI and MVVM, I have written books and even created courses on SwiftUI and MVVM. But each time I implemented a solution in SwiftUI and MVVM I felt not comfortable. The main reason was that I was always fighting the framework. When you work on a large application using MVVM with SwiftUI and you want to share state then it becomes extremely hard.

  • 2/N The main reason is that you cannot access EnvironmentObject inside the view model. Well, you can pass it using a constructor but then it becomes pain to do it again and again. And most of the time, you end up getting confused that who is managing the state. I ran into this scenario multiple times and I had to take alternate routes to resolve this problem. One thing to note is that React and Flutter (as original poster said) does not use MVVM pattern. React uses context or Redux and Flutter

Replies

Making a simple social network app from Apple WWDCs CoreData example, in this case the data model is defined in the backend.

The use case, what user can do in the system and the dependencies.

ERD of database and REST API endpoints

Now the data access model (API integration) in Swift. In case the data model defined in app you use the CoreData stack + objects and this is your model. Here you can do Unit / Integration tests.

In this case the data are external and you need a model to load (or aggregate) the data in memory and use it: PostStore and TagStore. In case of local data (using CoreData) you don’t need the stores, use the SwiftUI features.

Thank you @Appeloper for your reply,

store is about data not view (e.g. there’s an error info not a “popup”) : popup is just a struct that handle error & success messages

enum PopupType {
    case success
    case failure(Int)
    case info
    case warning
    case custom(String)
}

struct Popup: Identifiable {
    var id = UUID()
    var type: PopupType
    var title: String?
    var message: String?
    var closeText: String?
    var confirmText: String?
}
func load() async {
    isLoading = true
    do {
        let creatorsResponse = try await service.fetchPaymentCreditors()
        let formResponse = try await service.fetchPaymentForm()
        let unpaidResponse = try await service.fetchPaymentUnpaid()

        creators = creatorsResponse.data
        form = formResponse.data
        unpaid = unpaidResponse.data
    } catch {
        loadError = error
    }
    isLoading = false
}

I don't want to call all endpoints one after other, because I have three screens :

PaymentCreditorListView --> call fetchPaymentCreditors() and after user choose creditor I need to call fetchPaymentForm() that take creditor as parameter and then push to PaymentFormView (I need to save creditor to use it later in PaymentConfirmationView)

PaymentFormView --> When user press continue I need to call fetchPaymentUnpaid() that take form info as parameter and then push to PaymentUnpaidView() (I need to save form info & unpaid list to use it later in PaymentConfirmationView)

How can I handle this with their popups for each view using PaymentStore ? and if I need to split it as you said, we will not return to MVVM each view has his own store ?? because as soon as we have many endpoint, it become hard to handle each popup without firing others because they share same publisher

Also how can I handle push after endpoint finish if I can't add navigationPath inside store (because you said store have only Data)

Thank you

By design you can only had one alert at the time. Alerts are more for situations like user actions like send / submit, for the loading data cases you should use empty states. See other apps when a network error appear or no data loaded all at.

NavigationStack path is all about data, think data, not views. SwiftUI is data-driven nature.

Sometimes it feels we are doing MVVM but is different. In classic MVVM ViewModel (presentation logic) handle every View logic & state, each View have one ViewModel. The MVVM used in other platforms last years are not the true MVVM… sometimes ViewModel acts like a Store.

  • ViewModel -> Lives in a middle layer, presentation logic, a Controller with databinding.
  • Store -> Lives in a model layer, data logic

The problem I have is how I can handle errors/success for each view, because sometimes after error or success confirmation I need to do something different for some views. it will not work as I want if I add one sheet in root, let me give you an example:

In PaymentFormView I have button that display OTPView, after user enter code I call addToFavorite endpoint that get favoriteName from that view and if the code is wrong addToFavorite throw error, and when user confirm I need to display again OTPView and if it success I display success popup and after confirmation I need to pop to first view.

In PaymentConfirmView I have other scenario, I call submit endpoint and then I display success popup and after confirmation I need to push to other view

As you can see each view have a different staff to do after popup confirmation, If I add one sheet in root, is impossible to do this.

Is it a good idea to move do catch to view instead of store ??

class PaymentStore: ObservableObject {
    
    @Published var creditors: [Creditor] = []
    @Published var form: PaymentForm?
    @Published var unpaid: PaymentUnpaid?
    
    private let service: PaymentService
    
    init(service: PaymentService = PaymentService()) {
        self.service = service
    }
    
    func getPaymentCreditors() async throws {
        creditors = try await service.fetchPaymentCreditors()
    }
    
    func getPaymentForm() async throws {
        form = try await service.fetchPaymentForm()
    }
    
    func getPaymentUnpaid() async throws {
        unpaid = try await service.fetchPaymentUnpaid()
    }
}
struct PaymentCreditorListView: View {
    @EnvironmentObject private var store: PaymentStore
    @State private var idLoading = false
    @State private var popup: Popup?
   
    var body: some View {
        VStack {
        }
        .task {
            do {
            isLoading = true
            try await store.fetchPaymentCreditors()
            isLoading = false
        } catch {
            isLoading = false
            popup = .init(error: error)
        }
        .progress($isLoading)
    }
}

Hi OuSS_90,

For the case you have a single source of truth and progressive / lazy loading, you can have multiple errors (or popups) in the store. Example:

class PaymentStore: ObservableObject {
    @Published var creditors: [Creditor] = []
    @Published var form: PaymentForm? = nil
    @Published var unpaid: PaymentUnpaid? = nil

    @Published var isLoadingCreditors: Bool = false
    @Published var isLoadingForm: Bool = false
    @Published var isLoadingUnpaid: Bool = false

    @Published var creditorsError: Error? = nil // Popup
    @Published var formError: Error? = nil // Popup
    @Published var unpaidError: Error? = nil // Popup
    
    private let service: PaymentService
    
    init(service: PaymentService = PaymentService()) {
        self.service = service
    }
    
    func loadCreditors() async {
        creditorsError = nil
        isLoadingCreditors = true
        do {
            creditors = try await service.fetchPaymentCreditors()
        } catch {
            loadCreditorsError = error
        }
        isLoadingCreditors = false
    }

    func loadForm() async {
        formError = nil
        isLoadingForm = true
        do {
            form = try await service.fetchPaymentForm()
        } catch {
            loadFormError = error
        }
        isLoadingForm = false
    }

    func loadUnpaid() async {
        unpaidError = nil
        isLoadingUnpaid = true
        do {
            unpaid = try await service.fetchPaymentUnpaid()
        } catch {
            loadUnpaidError = error
        }
        isLoadingUnpaid = false
    }
}

Also you can have have an enum for load / error state:

enum State {
    case unloaded
    case loading
    case success
    case failure(Error)
}

…

@Published var creditorsState: State = .unloaded

Or some envelop for network data:

struct AsyncData<T> {
    var data: T
    var isLoading! bool = false
    var error: Error? = nil
}

…

@Published var creditors: AsyncData<[Creditor]>= AsyncData(data: [])

I wish real software was as simple as just loading data into an array to be displayed in a list as you seem to love to trivialise.

And what are store entities class Store: ObservableObject all about?

Lots of @Published properties and some services, they seem to share a lot with the good old ViewModels, but now the view can have multiple Stores, and that's better how?

Turns out real software is a bit more complex, eh?

The MVVM used in other platforms (Android, UIKit) last years isn’t the classic MVVM. What I read on most SwiftUI MVVM blog posts, they use the “Store pattern” but call it MVVM.

  • ViewModel -> Middle layer object, view dependent, presentation logic
  • Store -> Model object, view independent, reusable, shareable, data logic

In a real and big / complex project we have 7 Stores, if we followed the “classic” MVVM we end up with +40 ViewModels. We can use e.g. a ProductStore or CategoryStore in many Views, we can share the UserStore or ShoppingCart with many views in hierarchy. Many people do it but call it “ViewModel” (incorrect name).

How would you do to make a change on book from BookView and have that change reflect all the way up to BookList?

Hi jaja_etx, you can share the BookStore in the hierarchy with @EnvironmentObject or @ObservedObject and update the book.

// Books view
struct BookList: View {
    @StateObject private var store = BookStore()
    
    var body: some View {
        NavigationStack {
            List {
                ...
            }
            .task {
                await store.load()
            }
        }
        .environmentObject(store)
    }
}

// Book detail view
struct BookView: View {
    @State var book: Book
    @EnvironmentObject private var store: BookStore
    
    var body: some View {
        ScrollView {
            ...
        }
    }
    
    func toogleIsReaded() {
        book.isReaded.toggle() // Update the local state
        store.update(book) // Update the book on the store and send update to the web service if needed
    }
}
  • That is interesting. My mind is still tied up with View Models. It makes sense that I pass down the BookStore but still have a @State to handle the UI. I made some tests here and even with TextField this works well when I track the changes and send the updates to the BookStore.

  • Before I was thinking about creating a binding from the list to the detail. That would work to change the state, but in a real world app we want to save it to an API or data base. I also like having the store as the source of truth. This solution is so simple yet so strong. I really enjoy it.

    Thanks for answering!

Add a Comment

One of the coolest threads I've read. Reminds me of Neo, who escaped from the Matrix.

At the WWDC 2023 presentation, a new Observation framework and wrapper over CoreData, SwiftData, was introduced. After practicing with this new design pattern, I realized what the topic starter meant @Appeloper

Using ActiveRecord with SwiftData

Everything is simple and SwiftUI + UnitTest friendly. Remember that we can use SwiftData data in memory, not only in disk. Also we can use it for Web Service data requests creating a ”manager object” to sync Web Service data with our local data.

@Model
class Recipe {
    var name: String
    var description: String
    var ingredients: [Ingredient]
    var steps: [String]
}

// Creating data (factory methods)
extension Recipe {
    static var myFavorite: Self { ... }
    static var top30GordonRamsayRecipes: [Self] { ... }
    
    static func chatGPTSuggestion(text: String) -> Self { ... }
}

// Loading data (factory fetch descriptors)
extension Recipe {
    // @Query(Recipe.allRecipes) private var recipes: [Recipe] // Managed by SwiftUI
    // let recipes = try context.fetch(Recipe.allRecipes) // Managed by developer
    static var allRecipes: FetchDescriptor<Self> { ... }
    static var healthyRecipes: FetchDescriptor<Self> { ... }
    static func recipesWithIngredients(_ ingredients: [Ingredient]) -> FetchDescriptor<Self> { ... }
}

// Updating data
extension Recipe {
    func addSuggestedCondiments() { ... }
    func replaceUnhealthyIngredients() { ... }
    func reduceCaloriesByReduceOrReplaceIngredients(maxCalories: Double) { ... }
    
    func insert(context: ModelContext) { ... }
    func delete(context: ModelContext) { ... }
}

// Information
extension Recipe {
    var totalCalories: Double { ... }
    var isHealthy: Bool { ... }
}

---

@Model
class Ingredient { ... }

We can have one Recipe file:

  • Recipe.swift // Object + Tasks

Two Recipe files, if needed:

  • Recipe.swift // Object
  • Recipe+Tasks.swift // Tasks

The files we need for Recipe:

  • Recipe.swift // Object
  • Recipe+Creating.swift // Creating tasks
  • Recipe+Loading.swift // Loading tasks
  • Recipe+Updating.swift // Updating tasks
  • Recipe+Information.swift // Information tasks

Appreciate your point of view, but this absolutest way of communicating seems counterproductive. A single person cannot simply declare industry-wide standard architectural pattern just "wrong". MVVM is tried and true and SwiftUI is designed to work well with it, as clearly stated by the developer from Apple.

Post not yet marked as solved Up vote reply of jkim Down vote reply of jkim
  • There’s nothing wrong with MVVM. SwiftUI is modern, declarative and data-drive nature, also automatically do many VM / Controller tasks. MVVM is old, imperative + binding and event-driven. I’m just following the SwiftUI nature. You call Book (Model) and BookVM (ViewModel). I call Book and BookStore, both model objects. At the end is the same but with different names and layers.

Add a Comment

Hello Appeloper! First I just want to say I really do enjoy this approach versus MVVM. Being new to SwiftUI and seeing the MVVM approach it seemed so bloated and incredibly counter to what I saw in WWDC talks in their more simple demos (very much draw the rest of the owl).

I want to ask about what is the best approach for a Store approach when you have an Account object that retrieves information about a user (in my case firebase auth) and then uses an identifier in that auth information as a path identifier for another store (e.g. firestore).

I have a collection for say Books/<userId>/ and want to have a bookstore as you've described. Do you simply pass in the userId to the .loadStore function? Or should the BookStore contain a reference itself to the underlying auth provider?

@MainActor
class Account: ObservableObject {
    
    @Published var isLogged: Bool = false
    @Published var userDetails: UserDetails? = nil
    @Published var error: Error? = nil
    
    private var firebaseUser: User?
    private var authStateChangeHandler: AuthStateChangeHandler?
    
    private var userService: UserService = UserService.shared
    private var authService: AuthenticationService = AuthenticationService.shared
    
    private let logger = Logger(subsystem: "...", category: "Account")
    
    init() {
        authService.subscribeAuthStateChange { [weak self] (user: User?) in
            guard let self = self else { return }
            if let user = user {
                firebaseUser = user
                isLogged = true
            } else {
                firebaseUser = nil
                isLogged = false
            }
        }
    }
    
    func login(email: String, password: String) async {
        do {
            try await authService.login(email: email, password: password)
        } catch {
            self.error = error
        }
    }
    
    func register(email: String, password: String) async {
        do {
            try await authService.register(email: email, password: password)
        } catch {
            self.error = error
        }
    }
    
    func loadUser() async {
        do {
            guard let firebaseUser = firebaseUser else {
                throw UserError.noUserId
            }
            userDetails = try await userService.fetchUser(with: firebaseUser.uid)
            error = nil
            logger.info("User loaded \(firebaseUser)")
        } catch {
            self.error = error
        }
    }
}

class FirebaseAuthProvider: AuthenticationProvider {
    private let auth = Auth.auth() // <- should this become shared and used inside the stores?
}

  • Hi, you can pass on load(userId), more likely for this case, or define a BookStore.userId instance property.

Add a Comment

Hi @Appeloper, I have one question that has not been asked yet (or I have not seen it). You say that presentation logic now should be inside of the SwiftUI View instead of ViewModel/Presenter as it is in MVVM/MVP.

If I have User model (Active record), and there is a birthday attribute I would like to format it to string by using DateFormatter where should it be? Should I put DateFormatter inside of SwiftUI View? Should it be inside of UserStore? or anywhere else.

It seems kind of odd to me to put DateFormatter inside of the SwiftUI View. That's a simple data formatting, usually, we have more complicated data-view transformations. I do agree with you on many thoughts and ideas, however, I am missing "presentation layer" for a specific view. Something like View - Presentation - Model. (Though, I like the store idea that is agnostic of specific view and I am going to start using it.)

I understand that many times it is not needed however when presentation logic (data transformation to view representable data) becomes more complex so the View as well. For me, view and presentation are two different responsibilities. View should be responsible for how the view is rendered and composed, animations etc. And presentation should be about transforming data from model to view.

  • For this case you can define an User.formattedBirthday (or User.displayBirthday, or …) instance computed property. Or just use the Text(user.birthday, format: …). In many cases you can extend the DataFormatter with your custom formats.

Add a Comment

This is a fascinating discussion. However, I'm afraid I have to disagree with no VMs stance. Having worked on some pretty large apps built on SwiftUI - we hardly ever deal with a simple model-to-view mapping.

The view often comprises data collated from multiple sources (authentication, current contextual activity, A/B testing, remote configurations, user preferences, etc.) There is no one source of truth - there are many.

Secondly, whenever the user performs an action, the app can do many different things in response: E.g. post analytics, save data locally, do network requests, and perform side calculations. Yes, some of that can be abstracted to stores or service classes, but there is no one good place to orchestrate this flow.

This is where VMs excel. When done properly they are unit testable, predictable and reusable.

However, as always, use the right tool & abstraction for the job. If, for example, the view is a simple 1-to-1 mapping to its model, having a VM is probably overkill.

  • Well said. Pretty much encapsulates my thoughts on this thread

  • Just follow SwiftUI data-driven approach and everything will works. If you see my projects maybe you call VMs to my functional / state objects (store, context, …), the concept of MVVM changed in last years for non-Windows platforms. At the end I just focus in model objects with data, logic and other things and following the SwiftUI concepts.. no middle layer. My models includes all kind of non-UI objects (entities, stores, managers, …), MVVM models is just entities and logic move to ViewModel.

  • You call Book (Model) and BookVM (ViewModel). I call Book and BookStore, both model objects. At the end is the same.