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.

Hi Eddie,

Normally network data model != database (core data) model. You should have a separated data model and from my experience this save you from many problems. Also any networking cache should be done with default http, let the system do it, or custom saving the response on disk.

Keeping the some model data you can do this (using Active Record):

struct Channel: Identifiable, Codable {
    let id: String
    let name: String
    let genre: String
    let logo: URL?

    static func saveChannels(_ channels: [Self], on: …) async throws { … }

    // Factory Methods (set the data source object or protocol)
    static func all(on: …) async throws -> [Self] { … }
    static func favorites(on: …) async throws -> [Self] { … }
}

Using repository / manager pattern:

struct Channel: Identifiable, Codable {
    let id: String
    let name: String
    let genre: String
    let logo: URL?
}

protocol ChannelManager {
    static func loadAllChannels() async throws -> [Channel]
    static func loadFavoriteChannels() async throws -> [Channel]
    static func saveChannels(_ channels: [Channel]) async throws
}

struct NetworkChannelManager: ChannelManager {
    static func loadAllChannels() async throws -> [Channel] { … }
    static func loadFavoriteChannels() async throws -> [Channel] { … }
    static func saveChannels(_ channels: [Channel]) async throws { … }
}

struct LocalChannelManager: ChannelManager {
    static func loadAllChannels() async throws -> [Channel] { … }
    static func loadFavoriteChannels() async throws -> [Channel] { … }
    static func saveChannels(_ channels: [Channel]) async throws { … }
}

Example from Vapor platform:

Uses Active Record pattern for data access where you can set the data source.

// An example of Fluent's query API.
let planets = try await Planet.query(on: database)
    .filter(\.$type == .gasGiant)
    .sort(\.$name)
    .with(\.$star)
    .all()

// Fetches all planets.
let planets = try await Planet.query(on: database).all()

Hi @Appeloper,

Thank you very much for opening this amazing thread. I've read almost every post in this thread. I've never thought OOP and Model that deep before.

  • Thank you let me clearly know KISS vs SOLID.
  • Thank you let me clearly know the Data Access patterns: such as Active Record, Data Mapper, Repository, etc.
  • Thank you cleared my mind MV pattern vs MVC/MVVM/VIPER/VIP/MVI.
  • Thank you cleared my mind on how to design the Model layer (Data Objects + State Objects + Service Objects).

...

I really learned a lot from this thread. Amazing SwiftUI + MV pattern! Thank you very very much! Please keep rocking!

Software models are ways of expressing a software design, e.g. the Channel struct represents the model of a tv channel, the Program struct represents the model of a tv program, the folder / module / package LiveTV (that contains the Channel and Program structs) represents the model of a live tv system.

As said before, network data model != local database (core data) model. Also SwiftUI has a great integration with core data.

Hi @Appeloper, Im trying to use MV architecture in my project, below is a simple example of my code:

struct PaymentView: View {
    @StateObject private var store = PaymentStore()
    
    var body: some View {
        NavigationStack {
            PaymentCreditorListView()
            /* -> PaymentFormView() */
            /* -> PaymentUnpaidView() */
            /* -> PaymentConfirmationView() */
        }
        .environmentObject(store)
    }
}
class PaymentStore: ObservableObject {
     ....
    @Published var isLoading = false
    @Published var popup: Popup?
    
    private let service: PaymentService
    
    init(service: PaymentService = PaymentService()) {
        self.service = service
    }
    
    func getPaymentCreditors() async {
        do {
            isLoading = true
            let response = try await service.fetchPaymentCreditors()
            .....
            isLoading = false
        } catch {
            isLoading = false
            popup = .init(error: error)
        }
    }
    
    func getPaymentForm() async {
        do {
            isLoading = true
            let response = try await service.fetchPaymentForm()
            ....
            isLoading = false
        } catch {
            isLoading = false
            popup = .init(error: error)
        }
    }
    
    func getPaymentUnpaid() async {
        do {
            isLoading = true
            let response = try await service.fetchPaymentUnpaid()
            .....
            isLoading = false
        } catch {
            isLoading = false
            popup = .init(error: error)
        }
    }
}

On each view I use sheet to show popup error because sometimes I need to do something specific for that view (for ex: calling web service or redirection etc...)

.sheet(item: $store.popup) { popup in
    PopupView(popup: popup) 
}

The only problem I have right now is when one of the endpoints return an error, all the views that use the popups are triggred and I'm getting this warning message in the console "Attempt to present * on * which is already presenting...", same problem for progressLoader, it will fire all the other views.

Did I miss something with this approach ? or is there a better way to do it ?

Hi OuSS_90,

One fast way to fix the problema is to put the alert modifier on NavigationStack and not inside each view:

struct PaymentView: View {
    @StateObject private var store = PaymentStore()
    
    var body: some View {
        NavigationStack {
            PaymentCreditorListView()
            /* -> PaymentFormView() */
            /* -> PaymentUnpaidView() */
            /* -> PaymentConfirmationView() */
        }
        .sheet(item: $store.popup) { popup in
            PopupView(popup: popup) 
        }
        .environmentObject(store)
    }
}

But from your PaymentStore I think you are mixing different things or lazy load the store information. I don’t forget that store is about data not view (e.g. there’s an error info not a “popup”). Store should be as possible an data aggregator for some domain.

I don’t know the needs but here’s a example:

class PaymentStore: ObservableObject {
    @Published var isLoading = false
    @Published var loadError: Error? = nil
    
    private let service: PaymentService
    
    @Published var creditors: [Creditor] = []
    @Published var form: PaymentForm? = nil
    @Published var unpaid: ? = ? // Don’t know the type

    init(service: PaymentService = PaymentService()) {
        self.service = service
    }

    func load() async {
        isLoading = true
        do {
            let creatorsResponse = try await service.fetchPaymentCreditors()
            let formResponse = try await service.fetchPaymentForm()
            let unpaidResponse = try await service.fetchPaymentUnpaid()

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

Or split the PaymentStore into CreditorStore, PaymentForm and UnpaidStore, all depends on use case and how data is handled.

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
    }
}

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

Stop using MVVM for SwiftUI
 
 
Q