Model View Controller Store: Reinventing MVC for SwiftUI with Boutique

Jun 22, 2022
26 minute read

This Twitter thread offers a concise high level 13-tweet summary of this post's announcements, but you miss out on a lot of important detail that I highly recommend reading if you plan to start using the libraries I've developed and introduce in this post, or if you'd like to read an interesting technical walkthrough.

This post was updated on August 22, 2022 to reflect the v2 release of Boutique and related API changes.


Apple has never provided a blessed architecture for SwiftUI, and many developers have spent thousands of hours filling the gaps with their own ideas. A familiar approach for many developers is to take the MVVM pattern many people adopted in their UIKit/AppKit apps and translate it to the needs of SwiftUI. That can work well enough, but you begin to see some cracks in the architecture when you need to manage state and data flow due to how heavily SwiftUI leans on having a single source of truth. Others have taken the path of integrating powerful libraries such as The Composable Architecture which provide you with the tools to reason about your entire application. TCA takes inspiration from redux, more specifically The Elm Architecture, two patterns that are rather incredible in how they allow you to define your entire application as a tree of state. But TCA's great power comes with great responsibility a very high learning curve, which can make it difficult to learn personally. TCA's goals are much more of a fit for solving problems with a very high level of complexity, which may not be necessary for every app.

When it comes to developer ergonomics and program provability MVVM and TCA live on opposite ends of the spectrum. MVVM is lenient and depends on convention while TCA apps are built with rigidity in mind to guarantee correctness. This post isn't meant to be a tour of architectures though, I've mentioned those two patterns to show that there's a spectrum of how we develop software, and I believe there's room for something in between.


If SwiftUI shows us anything it's that declarative programming is the future of software development, and we need an approach to software development that leans into the strengths of the paradigm without being overly constrictive. What's needed isn't a whole new architecture, but to bring together tried and true patterns with a new concept, the Store. The Store is a very minimal yet powerful layer, which means the rest of your application's code can remain almost entirely unchanged. Your apps are yours, they are your expression of creativity, and you shouldn't have to change how you write code to turn your ideas into software.

The Store will be the heart of your app's data, and thanks to the declarative nature of SwiftUI an app's user interface is driven by its data. You can think the Store as the storage for your model objects, in SwiftUI this would be your single source of truth. If you model your data correctly then your user interface will always do what you expect it to do. That relationship between data and user interface is why Views having a single source of truth is so important.

I've built a batteries-included Store that comes with everything you'll need out of the box called Boutique to be the foundation for that data. Boutique does no behind the scenes magic and doesn't resort to shenanigans like runtime hacking to achieve a great developer experience. Our Store is extremely simple and has no complicated abstractions, you'll be surprised how little there is to learn. There entire API surface is only one public property, an array of items, and only three functions, add(), remove(), and removeAll(). Once you experience Boutique it becomes a must-have feature for any state-driven app you build. We'll dive much deeper into how the Store works under the hood to make this possible later in this post, build a Model View Controller Store app to see how it all comes together.

Boutique's Store is a dual-layered memory and disk cache which lets you build apps that update in real time with full offline storage and an incredibly simple API in only a few lines of code. That may sound a bit fancy, but all it means is that when you save an item into the Store, it also saves that item to disk. This persistence is powered under the hood by Bodega, an actor-based library I've developed for saving data or Codable objects to disk. In this post we'll use Boutique to show how the Store integrates into your apps.

The pattern and concepts are familiar and straightforward whether you've been a programmer for a few months or have been writing code for decades. After integrating this architecture into multiple apps without a hitch over the last few months I feel confident sharing it, and open sourcing the libraries that make it easy to build realtime offline-ready apps with just a few lines of code.

Model View Controller Store

Model View Controller Store is a rethinking of an architecture familiar to iOS developers MVC, for SwiftUI. While this post discusses SwiftUI, all of these techniques are applicable for building a UIKit or AppKit app. You can build apps across many platforms using this pattern, in fact the C from MVCS is inspired by Data Controllers rather than ViewControllers, but in this post we'll focus on SwiftUI. Your app's Models and Views will require no changes to your mental model when you think about a Model or View in a SwiftUI app, but we'll be adding the Store to our apps. The combination of MVC and a Store bound together by a simple API allows a developer to give their app a straightforward and well-defined data architecture to create an app that's incredibly easy to reason about.

The best way to explain Model View Controller Store is to show you what it is. The idea is so small that you may even be an expert before this post is over, there's actually very little to learn. Model View Controller Store doesn't require you to change your apps, and I'm going to use Boutique for the Store in our app.

You may prefer use Boutique on its own without Controllers, Model View Store is a perfectly acceptable architecture that some people will prefer. While I prefer having a data controller to mediate data and interactions between different parts of the app, especially as apps get more complex, it's not a prerequisite to using Boutique's Store in your app. We'll touch on using Boutique to create a Model View Store architecture in the Store section of the post, and discuss what the Store can bring to any app.


Let's Build An App

To demonstrate Model View Controller Store in practice we'll walk through a simple app that's representative of most apps people work on. You can find the full source code available on Github, but we'll also walk through a condensed version below.


This app will allow us to

  1. Query an API for a red panda image.
  2. Favorite the red panda the API sent us, saving the image and associated metadata to our Store.
  3. See that when we save the red panda to our Store, the state changes are rendered across two separate views.
  4. Remove a red panda, deleting the image and associated metadata from our Store.
  5. See that when we remove the red panda to our Store the state change is rendered due to the state change.
  6. If we relaunch the app all of our favorited red pandas will be there because the Store not only saves data in-memory but also cached on disk.

Model

You can assume our app has a RemoteImage model to represent a remote image resource, we'll use it when we fetch and save our red panda images.

struct RemoteImage: Codable, Equatable, Identifiable {
	let createdAt: Date
	let url: URL
	let width: Float
	let height: Float
	let dataRepresentation: Data

	var id: String {
		url.absoluteString
	}
}


Store

We'll create our Store, using the Store provided by Boutique, though if you'd like to build your own it's possible too.1 The Store has a readonly array of items and only three operations to add(), remove(), and removeAll(). What that means is modeling our data is easy, and our views will always render what we expect due to that data being well-modeled. The Store's items property is a @Published property, and because of that our Views update in realtime without having to build any observable behaviors, it's almost magical to watch.

To create a Store all you need is a storage and a cacheIdentifier.

  • The storage parameter is of the type StorageEngine, a data storage mechanism provided out of the box by Bodega. A StorageEngine can be a database, but it doesn't have to be. Bodega provides two built-in StorageEngine options, DiskStorageEngine and SQLiteStorageEngine. DiskStorageEngine works by saving your data as files to the file system, and SQLiteStorageEngine creates an SQLite database as the underlying storage mechanism. If you use either of these you'll never have to think about how the data is being stored, but Boutique v2 introduces the concept of BYOD (Bring Your Own Database), allowing you to build your own StorageEngine. You can create a CoreDataStorageEngine, a RealmStorageEngine, or even a CloudKitStorageEngine that matches your app's backend schema, the possibilities are endless. Bodega's documentation provides much more context, but for the purposes of our app we'll use the suggested default, SQLiteStorageEngine.
  • The cacheIdentifier is a KeyPath<Model, String> that your model must provide. That may seem unconventional at first, so let's break it down. Much like how protocols enforce a contract, the KeyPath is doing the same for our model. To be added to our Store and saved to disk our models must conform to Codable & Equatable, both of which are reasonable constraints given the data has to be serializable and searchable. But what we're trying to avoid is making our models have to conform to a specialized caching protocol, we want to be able to save any ol' object you already have in your app. Instead of creating a protocol like Storable, we instead ask the model to tell us how we can derive a unique string which will be used as a key when storing the item.

In this case we'll generate the cacheIdentifier by using the absoluteString of RemoteImage.url. If you try to save an item with the same cacheIdentifier as an item that already exists in the Store the newer item will override the older saved item. Because of that it's important that your cacheIdentifier is unique, but it also means that our Store will only need an add() method and no update(), with no checks necessary to see if an item is in the Store before adding a new item. 2

// You can create a Store anywhere but for a globally available Stores like an image cache
// I like to do it in an extension on Store so you can later reference it as `.imagesStore`.
extension Store where Item == RemoteImage {

	static let imagesStore = Store<RemoteImage>(
		storage: SQLiteStorageEngine.default(appendingPath: "Items"),
		cacheIdentifier: \.id
	)

}
  • For models that conform to Identifiable where the id is a String we've we can infer the cacheIdentifier. That leads us to a nice ergonomic improvement where we skip the Store's cacheIdentifier parameter, instead writing:
static let imagesStore = Store<RemoteImage>(
	storage: SQLiteStorageEngine.default(appendingPath: "Items")
)

You can initialize your Store anywhere you like, but I chose to initialize the imagesStore in an extension on Store. Caching images is often a task that you want to have available globally, and by choosing to initialize it on Store the we can drop the Store. prefix when we initialize it, leaving us with this aesthetically pleasing @Stored(in: .imagesStore) syntax.

You may be asking yourself "can I put all my data in one store" and the answer is sure, but it's not recommended. I highly recommend making many small stores, especially for a SwiftUI app. Any SwiftUI app, not just an app powered by Boutique's Store, will often have to re-render state changes if the source of truth is too centralized. To work within the frameworks' constraints, what I recommend is using a few smaller stores scoped to a certain model, or usage domain.

A Model View Store Interlude

Before we get to the Controller I'd like to talk about Model View Store. If you were to pursue the Model View Store approach then much of the Controller functionality would move to the View. If you're building an app that focuses on rendering local data you can find success using the Model View Store pattern. But as your app grows in scope, especially if it's interfacing with other data sources such as APIs or external storage services such as CloudKit then the Controller layer will become invaluable. That's because each View will have to duplicate the related logic, and a Controller will be able to not only centralize and prevent you from duplicating that logic, but provide you a domain-specific object for handling those operations.

If you're prototyping or building something simple Model View Store may be enough for you, but you may hit some growing pains as your app scales in complexity, and at that point I recommend incorporating a layer of abstraction for handling the business logic and interactions in your app.


Controller

That's all the setup we needed to create the Store in Model View Controller Store, only one line of code. There are many opinions about the correct shape for a Controller to take on, and that's why Controllers come in many flavors. If you ask 10 programmers how they would design their Controller you’ll get 12 answers. Some people prefer Thin Controllers, others lean towards Thick Controllers, and there's also Data controllers or Model Controllers. Some people don't even like to use the Controller nomenclature, they name these intermediate objects XYZManager, ABCFeature, or an object name specific to their app. As I said that your apps are yours and the way I shape them is your choice, and we don't need to impose an overly specific Controller paradigm to build a Model View Controller Store app.

Below we'll create something based on my preference of using Data Controllers. Our Controller will act as a centralized pass-through layer for operations such as hitting API to fetch images, saving those images to our Store, and handling all of the interactions related to images. We've included other CRUD operations such as delete or delete all, as Data Controllers often do.

/// A controller that allows you to fetch images remotely, and save or delete them from a `Store`.
final class ImagesController: ObservableObject {

	/// The `Store` that we'll be using to save images.
	@Stored(in: .imagesStore) var images

	/// Fetches `RemoteImage` from the API, providing the user with a red panda if the request succeeds.
	/// - Returns: The `RemoteImage` requested.
	func fetchImage() async throws -> RemoteImage {
		// Hit the API that provides you a random image's metadata
		let imageURL = URL(string: "https://image.redpanda.club/random/json")!
		let randomImageRequest = URLRequest(url: imageURL)
		let (imageResponse, _) = try await URLSession.shared.data(for: randomImageRequest)

		return RemoteImage(createdAt: .now, url: imageResponse.url, width: imageResponse.width, height: imageResponse.height, imageData: imageResponse.imageData)
	}

	/// Saves an image to the `Store` in memory and on disk.
	func saveImage(image: RemoteImage) async throws {
		try await self.$images.add(image)
	}

	/// Removes one image from the `Store` in memory and on disk.
	func removeImage(image: RemoteImage) async throws {
		try await self.$images.remove(image)
	}

	/// Removes all of the images from the `Store` in memory and on disk.
	func clearAllImages() async throws {
		try await self.$images.removeAll()
	}

}

Here we've referenced .imagesStore inside of ImagesController, but you may prefer to decouple your Store from the Controller it's used in. I would personally recommend taking this approach, and it's easily doable, only requiring you to change three lines of code.

final class ImagesController: ObservableObject {

	// This line of code goes away
	// @Stored(in: .imagesStore) var images

	// Instead we create an uninitialized property and a new initializer
	@Stored var images: [RemoteImage]

	init(store: Store<RemoteImage>) {
		self._images = Stored(in: store)
	}
	
	// The rest of the controller looks exactly the same
}

Injecting a Store into your Controller means that your Controller's actions now act independently from their state, and as a result become more testable. You can even create test Stores that are injected for the purposes of unit testing.3 Testability is usually the primary selling point for using MVVM, but we've obviated the need for a ViewModel. Our data manipulation and business logic resides in our Controller, and our Views will interface directly with the Controller. This allows us to remove one layer of abstraction from each View, no longer needing to pair a ViewModel with every View. And speaking of the View…


View

Now we have a Store that stores our RemoteImages, and an ImagesController that can fetch images for us. All we need is to render them so our users can look at some red pandas, and that's a job for the View. To remind you our view will look like this. This code is a little different from the code on GitHub but it's fundamentally the same, mainly some styling modifiers and additional state related to managing the view's user experience were removed for brevity's sake.

App Demo Card View

We'll build a card view that looks like this, with three interactions.

  1. When the view appears, we call imagesController. fetchImage() to set the current displayed image.
  2. When the user taps Fetch, we call the same function to fetch a new random red panda.
  3. When the user taps Favorite, we call imagesController.saveImage (image: self.currentImage) to save the current image to the Store.4
struct RedPandaCardView: View {

	@StateObject private var imagesController = ImagesController()

	@State private var currentImage: RemoteImage

	var body: some View {
		VStack {
			RemoteImageView(image: currentImage)
				.aspectRatio(CGFloat(currentImage.height / currentImage.width), contentMode: .fit)
				.primaryBorder()
				.cornerRadius(8.0)

			Spacer()

			Button(action: {
				Task {
					try await self.setCurrentImage()
				}
			}, label: {
				Label("Fetch", systemImage: "arrow.clockwise.circle")
					.font(.title)
					.frame(height: 52.0)
					.background(Color.palette.primary)
			})

			Button(action: {
				Task {
					try await self.imagesController.saveImage(image: self.currentImage)
					try await self.setCurrentImage()
				}
			}, label: {
				Label("Favorite", systemImage: "star.circle")
					.font(.title)
					.frame(height: 52.0)
					.background(Color.palette.secondary)
			})
		}
		.task({
			try? await self.setCurrentImage()
		})
	}
	
	private func setCurrentImage() async throws {
		self.currentImage = try await self.imagesController.fetchImage()
	}

}


In the RedPandaCardView section I wrote there were three interactions.

  1. When the user taps Favorite, we call imagesController. saveImage (image: self.currentImage) to save the current image to the Store.

But technically there's a step 4.

  1. When the user taps Favorite the changes will propagate to anyone who's using that Store to power their view. We're going to build a FavoritesCarouselView that has a reference to the same Store, that way we maintain one source of truth. Even though our two Views have two different instances of ImagesController, those ImagesController instances share the same underlying Store. This means that when the RedPandaCardView sends an action to save the red panda image to our Store, the FavoritesCarouselView will automatically update with the newly favorited image in real time!

App Demo Card View


struct FavoritesCarouselView: View {

	@StateObject private var imagesController = ImagesController()

	var body: some View {
		VStack {
			HStack {
				Text("Favorites")
					.bold()

				Spacer()

				Button(action: {
					Task {
						try await imagesController.clearAllImages()
					}
				}, label: {
					Image(systemName: "xmark.circle.fill")
						.foregroundColor(.red)
				})
			}

			HStack {
				CarouselView(
					items: self.imagesController.images.sorted(by: { $0.createdAt > $1.createdAt}),
					contentView: { image in
						RemoteImageView(image: image)
							.primaryBorder()
							.centerCroppedCardStyle()
					}
				)
				.transition(.move(edge: .trailing))
				.animation(.default, value: self.imagesController.images)
			}
		}
	}

}

You're probably thinking "this looks pretty much exactly like any SwiftUI app", and reader, you are right. That's the point. As linguists know one word can make a big difference, and in this case that one word is @Stored.


Editor's note: From this point on the blog post is going to get more technical and it is not required reading for using Model View Controller Store. If you're not interested in the underpinnings of the Store and @Stored you can skip to the conclusion.


Wandering Around The Boutique

So what is that @Stored? @Stored is a property wrapper that makes interacting with the Store go from straightforward to downright magical. But before we can get to the property wrapper, let's walk through how the Store is built, that way we can see how @Stored layers on top of it.

Let's break down how the Store is built.

public final class Store<Item: Codable & Equatable>: ObservableObject {

	// More code here…

	private let storageEngine: StorageEngine
	private let cacheIdentifier: KeyPath<Item, String>

	@MainActor @Published public private(set) var items: [Item] = []

	public init(storage: StorageEngine, cacheIdentifier: KeyPath<Item, String>) {}

	public func add(_ item: Item) async throws {}
	public func add(_ items: [Item]) async throws {}
	public func remove(_ item: Item) async throws {}
	public func remove(_ items: [Item]) async throws {}
	public func removeAll() async throws {}

	// More code here…

}

This is an abridged version of the Store, removing some details to make it easier to touch on the most important parts.

  1. Store is an ObservableObject, which means it can publish any changes that occur to the Store's @Published properties.
  2. Store contains one @Published property, items, the array representing the items we're storing.
  3. All @Published property changes must dispatch on the main actor, so we provide items with a @MainActor annotation.
  4. The access control of items is public private(set), that's to ensure that all mutations occur by using add(), remove(), and removeAll().
  5. Since items is public private(set) that means we can read and access items without any additional methods. The simplest API is one that doesn't require additional abstraction, so there's no reason to add superfluous methods for read operations, we can treat items just like any other array.
  6. You can even use the $items Publisher to subscribe changes, and this is what makes it possible to build SwiftUI apps that automatically update everywhere from a single source of truth.

A few additional details that are relevant, but not as important to explore.

  1. The storageEngine property is an on-disk cache provided by Bodega. This is what's responsible for writing items to disk, all of which happens for you automatically. Because storageEngine is private you'll never have to directly interact with it, but you can use it separately from the Store if you only want to save files to disk.
// Don't do this
try await self.$images.removeAll()
try await self.$images.add(images)

// Do this!
try await self.$images
	.removeAll()
	.add(images)
	.run()
  1. Boutique provides a fluent syntax for chaining functions that modify items together. This provides performance benefits as each function call will modify items, and since items is annotated with @MainActor any modification will trigger observing SwiftUI Views to re-render. By using .run() to we're able to tell the Store to use a different variant of removeAll() and add() that will run all of the chained commands together. This ensures a seamless user experience with the View batching together the modifications of items, redrawing the contents of your View only once.

That's all you need to know about the Store, and when you break it down there's nothing too complicated happening here. As a consumer of this API you never even have to deal with or understand any complexity, all you have to do is use the add() and remove() operations and you get the power of Store.

To recap

A single source of truth, full offline support, and real time state updates automatically. These are not three separate libraries, they're one library and we're calling it Boutique.

— Steve Jobs


We've finally reached the point where I explain @Stored, and here's where we encounter some real complexity. It's very important to state that you absolutely do not need to know this to use Boutique or to understand Model View Controller Store, I just found this to be an interesting technical challenge. 98% of the credit for this code goes to @harshil and @iankeen. Harshil provided the idea and strongly guided me to the initial implementation for the @Stored property wrapper, and Ian came up with the brilliant subscript that allows the property wrapper's enclosing object to republish the underlying Store's changes without adding five lines of boilerplate for a developer to write whenever creating a Store. A huge and heartfelt thank you to both of you.

@propertyWrapper
public struct Stored<Item: Codable & Equatable> {

	private let box: Box

	public init(in store: Store<Item>) {
		self.box = Box(store)
	}

	@MainActor
	public var wrappedValue: [Item] {
		box.store.items
	}

	public var projectedValue: Store<Item> {
		box.store
	}

	@MainActor public static subscript<Instance: ObservableObject>(
		_enclosingInstance instance: Instance,
		wrapped wrappedKeyPath: KeyPath<Instance, [Item]>,
		storage storageKeyPath: KeyPath<Instance, Stored>
	) -> [Item] where Instance.ObjectWillChangePublisher == ObservableObjectPublisher {
		let wrapper = instance[keyPath: storageKeyPath]

		if wrapper.box.cancellable == nil {
			wrapper.box.cancellable = wrapper.projectedValue
				.objectWillChange
				.sink(receiveValue: { [objectWillChange = instance.objectWillChange] in
					objectWillChange.send()
				})
		}

		return wrapper.wrappedValue
	}
}

As a reminder, this is what our usage of @Stored looks like.

final class ImagesController: ObservableObject {

	@Stored(in: .imagesStore) var images

	// More code here…

}
  1. The wrappedValue is an array of the items in the Store, in our case images. This allows our Store's items to be exposed to callers as images, providing a slick experience now that reading images is just like reading any other array.
  2. The projectedValue is a representation of the Store which allows us to call self.$images.add(image) in a manner that doesn't expose any of the underlying details.
  3. The subscript is a trick I learned from @johnsundell's post, and was able to implement with Ian's help. The code looks rather complex but is simpler when you break it down into plain English. It's important to know that if you have an ObservableObject inside of an ObservableObject, the outer ObservableObject has to manually republish the inner ObservableObjects changes for them to be reflected in your app. I honestly don't know why this is the case, but it's been well-documented as the way SwiftUI works.
  4. In our subscript the first parameter is the _enclosingInstance, which is the object that holds a reference to this property wrapper. In our case that's ImagesController, so when this subscript is invoked (transparently on any objectWillChange changes), ImagesController will republish changes from our Store. This technique is useful on it's own, and I will be incorporating it into a @Republished property wrapper.5

Conclusion

Phew, that was a bit of a deep dive, so let's come up for air and discuss at a high level what we've done. We talked about the tradeoffs of modern architectures used to build SwiftUI apps, and showed that there's space for Model View Controller Store in between MVVM and TCA. We introduced a new concept, the Store, and a batteries-included implementation with everything you'll need called Boutique that lets you build realtime offline-ready apps with just a few lines of code. We reviewed a condensed version of a full Model View Controller Store app, available on Github, and broke down its constituent parts, the Model, View, Controller, and Store. And then we did a technical breakdown of Boutique. It may look like there's a lot here, but it takes a lot of work to make something simple to use.

As a developer you'll get to take advantage of all this infrastructure to build SwiftUI apps in the same straightforward and familiar manner you're used to, without making any changes or compromises. Model View Controller Store is so familiar that I'm almost hesitant to even label this a new architecture. Model View Controller Store is more of a modern day twist on an old architecture that's been proven to work over decades. Please let me know if you're using Boutique or Model View Controller Store in your apps, I've opened up Discussions on GitHub for Boutique. And if you have any thoughts, any ways to improve the what I've developed, I'm always available on Twitter. I welcome any and all polite discussions.

Thank you so much for reading all of this, and a special thank you to Conrad Stoll for the multiple editing passes, and Romain Pouclet for this post's amazing header artwork. Most importantly, happy hacking! I can't wait to see what you all build. 🧑🏿‍💻🧑🏼‍💻👩🏽‍💻🧑🏻‍💻👩🏼‍💻


  1. It's possible to create a Store backed by any database or storage system, or even no persistent storage system. Boutique's Store and @Stored can serve as a good reference implementations for you to create a similar construct. Rather than creating a Store protocol I'll give you a blueprint for defining your own Store in plain English, or you can read this section for a much more in depth explanation..

    1. Define one property, @MainActor @Published public private(set) var items, to expose the underlying stored data.
    2. Add five functions add(_ item: Item), add(_ items: [Item]), remove(_ item: Item), remove(_ items: [Item]), and removeAll() for the purposes of mutating the Store.
    3. The @Stored property wrapper should be identical, though you can add your functionality based on the needs of your Store if you choose.

  2. Saving an item with the same cacheIdentifier as an item that already exists in the Store the newer item will override the older saved item. The same applies to saving two items at once with the same cacheIdentifier. Bodega is the library which powers Boutique's SQLite storage, and it provides a CacheKey type that takes a URL or String, hashes it, and saves it to disk in a file-system safe manner. Since URLs can be 4096 characters and files on disk can only be 256 characters I recommend using this whenever using a URL as your cacheIdentifier, but using CacheKey shouldn't be necessary otherwise unless your identifiers are Strings that are long strings, longer than 256 characters, or contain characters that aren't file system safe. A shorter version of that, it's almost always the smart move to use CacheKey.

  3. A nice tip for unit testing is to create a Store using the .temporaryDirectory, that way your data won't be persisted past the tests' execution.

  4. If you had a server responsible for syncing favorites this would be a good place to call API.save(image: image) as well. Since that'll also be asynchronous you may take one of two strategies, both of which are valid.

    1. Wait until you get a response from the API telling you an image has been saved to the server, this is especially easy when using async/await.
    2. Add an isSynced boolean to your models and optimistically save your image to the Store before calling the API. When the API responds you would change isSynced from false to true, and show users in the user interface that their model has been synced to the server.

  5. @adam_zethraeus reached out to me and let me know that he'd built an @Republished property wrapper, and it is exactly what I would have extracted from this implementation so I thought it'd be useful share.

Joe Fabisevich is an indie developer creating software at Red Panda Club Inc. while writing about design, development, and building a company here at build.ms. Before all that he was working as an iOS developer on societal health issues @Twitter.

Like my writing? You can keep up with it in your favorite RSS reader, or get posts emailed in newsletter form. I promise to never spam you or send you anything other than my posts, it's just a way for you to read my writing wherever's most comfortable for you.

If you'd like to know more, wanna talk, or need some advice, feel free to sign up for office hours at no charge, I'm very friendly. 🙂