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
85k 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

I'd love if there was a GitHub repo anywhere with a simple version of the principles of this thread - I'm a .net dev moved over to iOS, our main app is mainly converted to SwiftUI but it's all MVVM, viewcontrollers, coordinators and some UIKIT bits so very hard to unpack without being a UIKIT dev previously

I have lots of SwiftUI gaps to fill so a sensible/simple structure to look at would be super useful!

I think you have many examples of it on web and WWDC sessions. There’s another guy leaving out MVVM, maybe he explain with some tutorials next months:

Another guy leaving out MVVM. Is testability really more important than using SwiftUl as intended? …sacrifice a lot of great SwiftUl's built in APIs. From my experience we don’t need VMs (MVVM) for testability or scalability.

Today many people talks about microservice (or modular) architectures. That concept are part of Apple platforms from 90s.

Thanks for this post @Appeloper. My team and me are reading through it and it's making us think a lot about our current practices and the architecture we are using.

There is something that is make me struggle a lot. With this approach your view have freedom to use one or more "Stores" if they need to, this is an important difference regarding MVVM where any view would have her own ViewModel, is this correct?

Then you could end up having something like this:

class AllChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    @Published var isLoading: Bool = false
    var loadError: Error? = nil

    func load() async {
        isLoading = true
        do {
            channels = try await Channel.channels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
class FavoriteChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    @Published var isLoading: Bool = false
    var loadError: Error? = nil

    func load() async {
        isLoading = true
        do {
            channels = try await Channel.favoriteChannels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
struct HomeChannelList: View {
    @StateObject private var allChannelStore = AllChannelStore()
    @StateObject private var favoriteChannelStore = FavoriteChannelStore()

    var body: some View {
        if ????isLoading???? {
            ProgressView()
        } else if ????isError???? {
            ErrorView()
        } else {
            ContentView()
        }
        .task {
            await allChannelStore.channels
            await favoriteChannelStore.channels
        }
    }
}

Here we have one view that uses two different stores as source of true since it has to show the Favorite channels, but also All channels. In this scenario how would you control the state the view is in (loading, success, failure, etc)?

My ideas are:

  • We could still have the state in each ObservableObject separately, but then we would need to add a bunch of logic after await favoriteChannelStore.channels to see if any of the two stores has given an error, or maybe (depending on the business logic) it could be okey to render only part of the view if one of the two hasn't failed.
  • Move everything to the view, so that she can control her own state. But I'm afraid that doing so you would end up with views that would controller their state and some other views that would have it controlled by the ObservableObject and I don't like this kind of inconsistencies.
  • Add another Store on top of Favorite channels and All channels store that would control this... which sounds a lot like going back to ViewModels

I have the feeling I'm missing something really basic here, and some insights on it would be really appreciated

  • Yes, SwiftUI View have freedom to use one or more "Stores" (or source of truths). SwiftUI View has many-to-many relationship, MVVM View has one-to-one (in some cases many-to-one). Also theres lots free features, e.g. using Core Data we almost don’t need na “Store” object.

Add a Comment

Everything depends on your needs. The “store” works like the working memory. We load & aggregate the data we need (to use and manipulate) from different sources. It become the source of truth and can be used (and shared) by many “views”. React / Flutter / SwiftUI patterns are modern evolution of old patterns (e.g. MVVM, MVC, …).

Data

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

    // Factory Methods
    static var all: [Self] { … }
    static var favorites: [Self] { … }
}

Example 1 - Handle individual sources

class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []

    @Published var isLoading: Bool = false
    var loadError: Error? = nil

    func loadAll() async {
        isLoading = true
        do {
            channels = try await Channel.all
        } catch {
            loadError = error
        }
        isLoading = false
    }

    func loadFavorites() async {
        isLoading = true
        do {
            channels = try await Channel.favorites
        } catch {
            loadError = error
        }
        isLoading = false
    }
}

Example 2 - Aggregate all related information

class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    @Published var favoriteChannels: [Channel] = []

    @Published var isLoading: Bool = false
    var loadError: Error? = nil

    func load() async {
        isLoading = true
        do {
            channels = try await Channel.all
            favoriteChannels = try await Channel.favorites
        } catch {
            loadError = error
        }
        isLoading = false
    }
}

Hi @Appeloper, I have some questions about the Store in this architecture called MV

  1. As I can see from the example above, your Store really looks like what they called View-Model in the MVVM architecture. From what I understand, the View-Model is exclusively belong to a specific View, while Store can be shared between multiple Views. Is there anything other differents or it's just that?

  2. If we mix UIKit and SwiftUI, I think the Store should be able to be shared with both kind of views, am I correct? I mean, if we use our plain old MVC, it's like your new idea about MV, where Model is still the same, and ViewController means View, and the Store should be able to shared between UIKit's ViewController and SwiftUI's View, right?

Hi Eddie,

  1. YES, correct and more… ViewModel also represents the presentation logic / state like “showConfirmationAlert”, “filterBy”, “sortBy”, “focus”, … In SwiftUI you can use @State properties for that. In MVVM the View state is binding to the ViewModel object. Store is more in “model” (or data) side.

  2. Yes you can use Store in UIKit (MVC) but remember that in Apple MVC the “Store” is what many of us call the “Model Controller”. (e.g. NSFetchedResultsController, NSArrayController, UIDocument, …).

In declarative platforms UI = f(State) (data-driven approach) you don’t need a middle man (controller, viewmodel, …). The ”Stores” are part of the model and works like the working memory, we use it to load / aggregate the data we need to handle (process, work, …).

I call “Store” (from React and WWDC videos) but we can call what we want and makes sense. We should just avoid to mix it with MVVM pattern.

Hi @Appeloper, I have question about MV architecture:

I have app with tabView (4 screens), one of them is called MenuView that contain all app features (more than 10). I want to follow MV architecture I need to declare all stores in menuView like this :

struct MenuView: View {
    @StateObject private var store1 = FeatureOneStore()
    @StateObject private var store2 = FeatureTwoStore()
    @StateObject private var store3 = FeatureThreeStore()
     ....
    
    // mode, ...
    
    var body: some View {
        NavigationStack(path: $navigation.path) {
             VStack {

             }
            .navigationDestination(for: ItemKey.self) { key in
                 // Push to features
            }
        }
        .environmentObject(store1)
        .environmentObject(store2)
        .environmentObject(store3)
     }
}

MenuView will end up with many stores, is that good ? or there is other solution

  • It’s ok if all that stores are shared from root view to deep views. But we try as possible to assign a store where is needed. If the store is only needed in one view we should keep only on that view, if a store is shared on 2 ou more views we should set as EnvironmentObject (some times as ObservedObject) on the point of hierarchy that makes sense. There’s no rules, only your views needs.

Add a Comment

Hi @Appeloper, in your previous post, you wrote this:

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

    // Factory Methods
    static var all: [Self] { … }
    static var favorites: [Self] { … }
}

My question is, what if you have multiple "source of truth", e.g. when offline, read from coredata, otherwise when online, read from firebase realtime db. If you write these logic inside the model (Channel), wouldn't it violate the purpose of the Model? Or maybe this is just an example, and in real case, you'd create another layer to retrieve the actual data? (Networking or offline storage)?

My concern is: In a decentralize environment, what if you have a model name ChatMessage, you can retrieve this model from different sources (different host urls, different api paths, local storage), how would you design your Model Object?

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!

Add a Comment

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.