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

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

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

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

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

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

@Published var selectedFolder: Folder? = nil

=

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

3 - Yes!

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

struct Product {
    // Properties (data)

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

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

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

or

struct Product {
    // Properties (data)

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

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

or

struct Product {
    // Properties (data)
}

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

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

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

Add a Comment

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

We see ObservableObject as:

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

ObservableObject (working, in-memory data) from:

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

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

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

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

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

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

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

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

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

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

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

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

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

The evolution:

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

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

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

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

  • Well you just move business logic from vm(c) to v and m. Mostly in m (with active record). So now we have fat model and some logic in view? What benefits?

Add a Comment

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

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

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

  • in the model - Probably.

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

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

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

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

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

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

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

  • Yes everything will be in model and model should not be POJOs. Inside model we can separate the things like Data, Network, Database, State / Stores. We have many patterns for data access (active record, data mapper, repository, …). You can call the stores / state as ViewModels and not using MVVM. The “MVVM pattern” becomes problematic (and unnecessary) with declarative UI frameworks (SwiftUI, Flutter, React, …).

  • Currently I’m working on big SwiftUI project, multi model, and I can tell “Active Record pattern” (data access) and “Store pattern” (state, source of truth) + @EnvironmentObject are our best friends.

Add a Comment

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

Yes 🙌

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

Another Example

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

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

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

Who needs a ViewModel today?

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

  • Hello and thank you for this awesome thread. I agree with you on this, and SwiftUI is a completely new way of thinking for iOS. It's like when you are developing using ECS instead of OOP. And again, it depends your app and/or feature. You are not limited to one-exclusive pattern or achitecture for your app.

    I have some questions about this, and your last post is perfect as an example.

  • With small data set you can have favorite channels in your Account object. Also you can remove subscription plans from Account and have a SubscriptionStore. We do what makes sense for our business. But just remember: ObservableObject is a Source of Truth, part of our model, not a ViewModel.

Add a Comment

If the user wants to favorite channels and VOD movies, how would you handle that?

1- A list of [Channel] and [Movie] in your AccountStore (user state/data) ? 2- Or if we want to keep only the ID (eg. [Channel.ID] + [Movie.ID]), we need to map these ids to their object after app launch. Is it the Account that access the data? or do you have a nested dependency to access the data only via ChannelStore and VODStore (requiring these stores load the data before to not have empty collection)

ChannelStore.load
VideoOnDemand.load
Account.load + map Channel/VOD.ID.

3- Can different store access the same underlying data via your Active record object? or if a View needs data from 2 different store to map them to a new object type.

4- Also curious about an active record example. what is inside these "..." in the static methods or functions. Movie.all { ??? } do you always call Webservice.shared.allMovies? are you using a cache? return Cacheservice.shared.allMovie ?? Webservice.shared.allmovies

thank you!

Simple question: what if the data format in your model does not correspond with how you want to show it on screen? E.g. it's not simply a String in your model that you show in a TextField component, but you need to split it over 3 different TextField components and merge it when you put it back in the model?

Unless I am mistaken I haven't seen one word about converting data.

Model vs Form (View)

What if the data format in your model does not correspond with how you want to show it on screen? Depends on your needs. From my experience I find using local state (1.2) for form data, then convert to your model the best approach. Form should follow the Model. Remember View = f(Model).

struct RegistrationInfo: Codable {
    var name: String = ""
    var email: String = ""
    var phone: String = ""
    var age: Int = 19
    
    func submit() async throws { ... }
}
​
// Convenience if needed
extension RegistrationInfo {
    var firstName: String { String(name.split(separator: " ").first ?? "") }
    var lastName: String { String(name.split(separator: " ").last ?? "") }
}
​
// 1.1 - Using view local state (direct), need a tricky solution
struct RegistrationForm: View {
    @State private var info = RegistrationInfo()
    
    @State private var isSubmitting: Bool = false
    @State private var submitError: Error? = nil
    
    var body: some View {
        Form {
            Section("Name") {
                TextField("First name", text: Binding(get: { info.firstName }, set: { value, _ in ???? }))
                TextField("Last name", text: Binding(get: { info.lastName }, set: { value, _ in ???? }))
            }
            
            // ...
            
            Button("Submit") {
                Task {
                    isSubmitting = true
                    do {
                        try await info.submit()
                    } catch {
                        submitError = error
                    }
                    isSubmitting = false
                }
            }
        }
    }
}
​
// 1.2 - Using view local state (indirect)
struct RegistrationForm: View {
    @State private var firstName: String = ""
    @State private var lastName: String = ""
    
    @State private var email: String = ""
    @State private var phone: String = ""
    @State private var age: Int = 18
    
    @State private var isSubmitting: Bool = false
    @State private var submitError: Error? = nil
    
    var body: some View {
        Form {
            Section("Name") {
                TextField("First name", text: $firstName)
                TextField("Last name", text: $lastName)
            }
            
            // ...
            
            Button("Submit") {
                Task {
                    isSubmitting = true
                    do {
                        let info = RegistrationInfo(name: "\(firstName) \(lastName)",
                                                    email: email,
                                                    phone: phone,
                                                    age: age)
                        try await data.submit()
                    } catch {
                        submitError = error
                    }
                    isSubmitting = false
                }
            }
        }
    }
}
​
// 2 - Using an external state, object part of your model
class Registration: ObservableObject {
    @Published var firstName: String = ""
    @Published var lastName: String = ""
    
    @Published var email: String = ""
    @Published var phone: String = ""
    @Published var age: Int = 18
    
    @Published var isSubmitting: Bool = false
    var submitError: Error? = nil
    
    func finish() async {
        isSubmitting = true
        do {
            let data = RegistrationInfo(name: "\(firstName) \(lastName)",
                                        email: email,
                                        phone: phone,
                                        age: age)
            try await data.submit()
        } catch {
            submitError = error
        }
        isSubmitting = false
    }
}
​
struct RegistrationForm: View {
    @State private var registration = Registration()
    
    var body: some View {
        Form {
            Section("Name") {
                TextField("First name", text: $registration.firstName)
                TextField("Last name", text: $registration.lastName)
            }
            
            // ...
            
            Button("Submit") {
                Task {
                    await registration.finish()
                }
            }
        }
    }
}
  • Your Registration class in example 2 is what most people would probably implement as RegistrationViewModel. I'm not 100% with you on the "all ViewModels must die" train yet, but I do agree it's completely useless in a lot of scenarios. I started thinking about it a bit more and in my current project I just deleted/refactored a whole bunch of files because of this, because I realised they didn't bring anything to the table except for boilerplate code.

  • Registration, ChannelStore, MovieStore, Account… are not ViewModels, but in some cases can feel like. MVVM (old tech) requires a middle layer and one ViewModel for each View. ViewModel is the state and handle tasks for that View. In my Movies example if you use MVVM you will implement a MovieListVM, MovieRowVM, MovieGridVM, MovieCardVM. SwiftUI View is a ViewModel, have local state, you can handle events inside, … you only need to give to View the Source of Truth(s). I only have a MovieStore.

  • Registration and other objects are Source of Truths, model objects, part of our model. That objects are independent from a specific view and this is one important difference from MVVM. I see, from other platforms some people use the name VM for the “Source of Truth” (shared, view independent, …) but this is not MVVM. Also we can’t share a state using true MVVM, for SwiftUI (and other declarative platforms) EnvironmentObject is key for many use cases and situations.

Channels

There’s many ways for it, depending on our needs. Just some ideas.

  • Typically a small & limited data set
  • For cache use HTTP / URLSession caching system (defined by the server)
  • For offline use the stores (no needed in many cases)

Model layer - Example 1

struct Channel: Identifiable, Hashable, Codable {
    let id: String
    // Data
    var isFavorite: Bool
    
    // Factory Methods
    static var channels: [Self] {
        get async throws {
            try await MyTVWS.shared.request(resource: "channels",
                                            verb: .get)
        }
    }
}
​
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    
    var favorites: [Channel] { channels.filter { $0.isFavorite } }
    
    @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
    }
}

Model layer - Example 2

Check if channel is favorite on the “favorites” store.

struct Channel: Identifiable, Hashable, Codable {
    let id: String
    // Data
    
    func addToFavorites() async throws { ... }
    func removeFromFavorites() async throws { ... }
    
    // Factory Methods
    static var channels: [Self] {
        get async throws {
            try await MyTVWS.shared.request(resource: "channels",
                                            verb: .get)
        }
    }
    
    static var favoriteChannels: [Self] {
        get async throws {
            try await MyTVWS.shared.request(resource: "favoriteChannels",
                                            verb: .get)
        }
    }
}
​
// 2.1
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.channels
            favoriteChannels = try await Channel.favoriteChannels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
// 2.2
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    enum Source {
        case all
        case favorites
    }
    
    private let source: Source
    
    init(_ source: Source) {
        self.source = source
    }
    
    func load() async {
        isLoading = true
        do {
            switch source {
            case .all:
                channels = try await Channel.channels
            case.favorites:
                channels = try await Channel.favoriteChannels
            }
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
// 2.3
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    open func load() async { }
}
​
class AllChannelStore: ChannelStore {
    func load() async {
        isLoading = true
        do {
            channels = try await Channel.channels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
class FavoriteChannelStore: ChannelStore {
    func load() async {
        isLoading = true
        do {
            channels = try await Channel.favoriteChannels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
// 2.4
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    open func loadChannels() async throws { }
    
    func load() async {
        isLoading = true
        do {
            try await loadChannels()
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
class AllChannelStore: ChannelStore {
    func loadChannels() async throws {
        channels = try await Channel.channels
    }
}
​
class FavoriteChannelStore: ChannelStore {
    func loadChannels() async throws {
        channels = try await Channel.favoriteChannels
    }
}

View layer - Based on Example 1

struct ChannelList: View {
    @EnvironmentObject private var channelStore: ChannelStore
    
    enum Mode {
        case all
        case favorites
    }
    
    @State private var mode: Mode = .all
    
    var body: some View {
        VStack {
            Picker("", selection: $mode) { ... }
                .pickerStyle(.segmented)
            
            ScrollView {
                LazyVStack {
                    switch mode {
                    case .all:
                        ForEach(channelStore.channels) { channel in
                            ChannelCard(channel: channel)
                        }
                    case .favorites:
                        ForEach(channelStore.favoriteChannels) { channel in
                            ChannelCard(channel: channel)
                        }
                    }
                }
            }
        }
    }
}
​
struct ChannelCard: View {
    var channel: Channel
    
    var body: some View { ... }
}
​
struct ProgramList: View {
    @EnvironmentObject private var channelStore: ChannelStore
    
    var body: some View { ... }
}
​
struct LivePlayerView: View {
    @EnvironmentObject private var channelStore: ChannelStore
    
    var body: some View { ... }
}

Movies

  • Typically a big & unlimited data set
  • For cache use HTTP / URLSession caching system (defined by the server)
  • For offline use the stores (no needed in many cases)

Model layer

struct Movie: Identifiable, Hashable, Codable {
    let id: String
    // Data
    
    enum Section: String, Codable {
        case all
        case featured
        case favorites
        case currentlyViewing
        // ...
    }
    
    enum Genre: String, Identifiable, Codable, CaseIterable {
        case action
        case comedy
        case drama
        case terror
        case animation
        case science
        case sports
        case western
        // ...
        
        var id: Self { self }
    }
    
    // Factory Method
    static func movies(pageNumber: Int = 1,
                       pageSize: Int = 30,
                       search: String? = nil,
                       section: Movie.Section = .all,
                       genres: [Movie.Genre] = [],
                       sort: String? = nil) async throws -> [Self] {
        try await MyTVWS.shared.request(resource: "movies",
                                        verb: .get,
                                        queryString: ...)
    }
    
    // --- or ---
    // (recommended)
    
    struct FetchOptions {
        var pageNumber: Int = 1
        var pageSize: Int = 30
        var search: String? = nil
        var section: Section = .all
        var genres: [Genre] = []
        var sort: String? = nil
    }
    
    static func movies(_ options: FetchOptions) async throws -> [Self] {
        try await MyTVWS.shared.request(resource: "movies",
                                        verb: .get,
                                        queryString: ...)
    }
}
​
class MovieStore: ObservableObject {
    @Published var movies: [Movie] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    var options: Movie.FetchOptions
    
    init(_ options: Movie.FetchOptions) {
        self.options = options
    }
    
    // Add convenience initializings if wanted
    init(_ section: Movie.Section,
         genre: Movie.Genre? = nil
         limit: Int = 15) {
        self.options = ...
    }
    
    func load() async {
        isLoading = true
        do {
            movies = try await Movie.movies(options)
        } catch {
            loadError = error
        }
        isLoading = false
    }
    
    func loadNextPage() async { ... } // Infinite scrolling
}

View layer

struct MovieList: View {
    enum Mode {
        case home
        case genres
    }
    
    @State private var mode: Mode = .home
    
    var body: some View {
        VStack {
            Picker("", selection: $mode) { ... }
                .pickerStyle(.segmented)
            
            ScrollView {
                LazyVStack {
                    switch mode {
                    case .home:
                        MovieRow(store: MovieStore(.featured))
                        MovieRow(store: MovieStore(.currentlyViewing))
                        MovieRow(store: MovieStore(.favorites))
                    case .genres:
                        ForEach(Movie.Genre.allCases) { genre in
                            MovieRow(store: MovieStore(.all, genre: genre))
                        }
                    }
                }
            }
        }
    }
}
​
// Each row can be limited to n items and have a "View all" button to push MovieGrid with all items
struct MovieRow: View {
    @StateObject var store: MovieStore
    
    var body: some View { ... }
}
​
struct MovieGrid: View {
    @StateObject var store: MovieStore
    
    var body: some View { ... }
}
​
struct MovieCard: View {
    var movie: Movie
    
    var body: some View { ... }
}
  • Brilliant 🤯

Add a Comment