Model View Controller Store: Reinventing MVC for SwiftUI with Boutique
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
- Query an API for a red panda image.
- Favorite the red panda the API sent us, saving the image and associated metadata to our Store.
- See that when we save the red panda to our Store, the state changes are rendered across two separate views.
- Remove a red panda, deleting the image and associated metadata from our Store.
- See that when we remove the red panda to our Store the state change is rendered due to the state change.
- 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 typeStorageEngine
, a data storage mechanism provided out of the box by Bodega. AStorageEngine
can be a database, but it doesn't have to be. Bodega provides two built-inStorageEngine
options,DiskStorageEngine
andSQLiteStorageEngine
.DiskStorageEngine
works by saving your data as files to the file system, andSQLiteStorageEngine
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 ownStorageEngine
. You can create aCoreDataStorageEngine
, aRealmStorageEngine
, or even aCloudKitStorageEngine
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 aKeyPath<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 ourStore
and saved to disk our models must conform toCodable & 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 likeStorable
, 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 theid
is aString
we've we can infer thecacheIdentifier
. That leads us to a nice ergonomic improvement where we skip theStore
'scacheIdentifier
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 RemoteImage
s, 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.
We'll build a card view that looks like this, with three interactions.
- When the view appears, we call
imagesController. fetchImage()
to set the current displayed image. - When the user taps Fetch, we call the same function to fetch a new random red panda.
- When the user taps Favorite, we call
imagesController.saveImage (image: self.currentImage)
to save the current image to theStore
.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.
- When the user taps Favorite, we call
imagesController. saveImage (image: self.currentImage)
to save the current image to theStore
.
But technically there's a step 4.
- 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 aFavoritesCarouselView
that has a reference to the sameStore
, that way we maintain one source of truth. Even though our two Views have two different instances ofImagesController
, thoseImagesController
instances share the same underlyingStore
. This means that when theRedPandaCardView
sends an action to save the red panda image to ourStore
, theFavoritesCarouselView
will automatically update with the newly favorited image in real time!
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.
Store
is anObservableObject
, which means it can publish any changes that occur to theStore
's@Published
properties.Store
contains one@Published
property,items
, the array representing the items we're storing.- All
@Published
property changes must dispatch on the main actor, so we provideitems
with a@MainActor
annotation. - The access control of
items
ispublic private(set)
, that's to ensure that all mutations occur by usingadd()
,remove()
, andremoveAll()
. - Since
items
ispublic private(set)
that means we can read and accessitems
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 treatitems
just like any other array. - 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.
- The
storageEngine
property is an on-disk cache provided by Bodega. This is what's responsible for writingitems
to disk, all of which happens for you automatically. BecausestorageEngine
isprivate
you'll never have to directly interact with it, but you can use it separately from theStore
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()
- Boutique provides a fluent syntax for chaining functions that modify
items
together. This provides performance benefits as each function call will modifyitems
, and sinceitems
is annotated with@MainActor
any modification will trigger observing SwiftUI Views to re-render. By using.run()
to we're able to tell theStore
to use a different variant ofremoveAll()
andadd()
that will run all of the chained commands together. This ensures a seamless user experience with the View batching together the modifications ofitems
, 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…
}
- The
wrappedValue
is an array of the items in theStore
, in our caseimages
. This allows ourStore
'sitems
to be exposed to callers asimages
, providing a slick experience now that readingimages
is just like reading any other array. - The
projectedValue
is a representation of theStore
which allows us to callself.$images.add(image)
in a manner that doesn't expose any of the underlying details. - 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 anObservableObject
, the outerObservableObject
has to manually republish the innerObservableObject
s 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. - 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'sImagesController
, so when this subscript is invoked (transparently on anyobjectWillChange
changes),ImagesController
will republish changes from ourStore
. 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. 🧑🏿💻🧑🏼💻👩🏽💻🧑🏻💻👩🏼💻
-
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 aStore
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..- Define one property,
@MainActor @Published public private(set) var items
, to expose the underlying stored data. - Add five functions
add(_ item: Item)
,add(_ items: [Item])
,remove(_ item: Item)
,remove(_ items: [Item])
, andremoveAll()
for the purposes of mutating the Store. - The
@Stored
property wrapper should be identical, though you can add your functionality based on the needs of yourStore
if you choose.
- Define one property,
-
Saving an item with the same
cacheIdentifier
as an item that already exists in theStore
the newer item will override the older saved item. The same applies to saving two items at once with the samecacheIdentifier
. Bodega is the library which powers Boutique's SQLite storage, and it provides aCacheKey
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 aURL
as yourcacheIdentifier
, but usingCacheKey
shouldn't be necessary otherwise unless your identifiers areString
s 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 useCacheKey
.↩ -
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.↩ -
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.- 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.
- 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 changeisSynced
fromfalse
totrue
, and show users in the user interface that their model has been synced to the server.
-
@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. 🙂