EP 296 · Cross Platform Swift · Sep 23, 2024 ·Members

Video #296: Cross-Platform Swift: Persistence

smart_display

Loading stream…

Video #296: Cross-Platform Swift: Persistence

Episode: Video #296 Date: Sep 23, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep296-cross-platform-swift-persistence

Episode thumbnail

Description

We round out our series with one more feature: the ability for our users to manage a list of their favorite facts. It will allow us to explore a complex side effect, persistence, and show how the same Swift code can save and load data across iOS app launches and web page refreshes.

Video

Cloudflare Stream video ID: 2485250888ddaadf59d97fbf55022234 Local file: video_296_cross-platform-swift-persistence.mp4 *(download with --video 296)*

Transcript

0:05

We have now added a brand new piece of functionality to our humble counter feature, that of a timer, and we approached this in a very interesting way. We first concentrated on the core domain of the feature, which is the observable model. We implemented the timer functionality in full isolation without a care in the world for the view. The timer functionality is already complicated enough on its own without having to worry about the view. It’s a long-living effect and we had to manage its lifecycle manually.

0:29

And then when it came time to update the views it was quite easy. We just had to add a button to the UI to invoke the appropriate method on the model. And our feature now works on two different platforms and many different view paradigms. Brandon

0:41

Let’s add one more feature to our little counter feature. Sometimes when I get a fact for a number I learn something very interesting about that number. Let’s make it so that we can save our favorite facts and display them in a list in our view. And further, we will make it so that we can delete any facts that we no longer like.

1:05

Let’s dig in. The saved facts feature

1:08

We are again going to approach this as a domain modeling exercise first. We will fully build out the feature in the observable model in isolation. This will help us concentrate on the core functionality without worrying about the view, and then later we will bend the will of the view to make it work with the model.

1:27

So, let’s add some state to our model to track the favorite facts: public var savedFacts: [String] = []

1:40

And next let’s think about how we want to save a new fact to this array.

1:45

Currently we show the fact in an alert using AlertState : alert = AlertState { TextState("Fact") } actions: { ButtonState { TextState("Save fact") } ButtonState(role: .cancel) { TextState("Cancel") } } message: { TextState(fact) }

1:57

And previously in this series we even introduced custom buttons in the alert by simply implementing an actions trailing closure and constructing some ButtonState values.

2:21

Now what we would like to do is execute some logic in our model when the “Save fact” button is tapped. This is completely supported by AlertState , and it can be accomplished by providing a custom type to its generic. Currently we are just using Never : public var alert: AlertState<Never>?

2:34

…because up until now we haven’t needed to execute any actions from the alert.

2:40

We now do want to execute some actions, and to do so we will define a new enum type that describes all the various actions that can be performed from the alert: public enum Alert { case saveFact(String) } And use this type for our alert state: public var alert: AlertState<Alert>?

3:15

And now we can associate this saveFact action with the ButtonState : ButtonState(action: .saveFact(fact)) { TextState("Save fact") }

3:27

When the alert is shown and the user taps on the “Save fact” button, a special handler closure will be called in order for us to execute custom logic. All of that will happen over in the view though, which is where we want to stay as logicless as possible, and so we will implement that handler as a method in the model: public func handle(alertAction: Alert) { }

4:13

And to implement this we can just switch on the action and implement the logic: public func handle(alertAction: Alert) { switch alertAction { case .saveFact(let fact): savedFacts.append(fact) } }

4:38

That is all it takes to save a fact, as far as the model is concerned. But let’s go ahead and go the extra mile by enabling the logic to delete a fact.

5:07

First we will create a method that can be called from the view that represents when the user taps on a “Delete” button next to a fact: public func deleteFactButtonTapped(fact: String) { }

5:24

Now we could of course just search through the savedFacts array to find the fact passed in, and then remove it. But let’s make things a little more interesting. What if we wanted to protect against the user accidentally tapping the “Delete” button by first asking them to confirm to delete the fact?

5:46

Alerts are a fantastic way to do this, and we already have all the infrastructure set up to handle any kind of alert. We can simply populate the alert field with any AlertState and it will be immediately displayed to the user.

5:52

So, let’s populate the alert state to ask the user if they are sure they want to delete the fact: public func deleteFactButtonTapped(fact: String) { alert = AlertState { TextState("Delete fact?") } actions: { ButtonState(role: .destructive) { TextState("Delete") } ButtonState(role: .cancel) { TextState("Cancel") } } message: { TextState(fact) } }

6:53

But we need to add a new action to our Action enum that represents the user tapping on the “Delete” confirmation button: public enum Alert: Sendable { case confirmDeleteFact(String) case saveFact(String) }

7:13

And we can associate that action with the “Delete” button: ButtonState(role: .destructive, action: .confirmDeleteFact(fact)) { TextState("Delete") }

7:20

We now have a compilation error in the handler method because we now need to implement the logic for this action: public func handle(alertAction: Alert) { switch alertAction { case .confirmDeleteFact(let fact): savedFacts.removeAll(where: { $0 == fact }) case .saveFact(let fact): savedFacts.append(fact) } }

8:06

And believe it our not, we have now implemented all of the logic necessary for this new functionality. It supports the full flow of the user being presented an alert for the fact, the user choosing to save the fact, and then the fact being added to the array in the model. And also the flow of the user tapping the “Delete” button next to a fact, the user confirming they want to delete the fact, and it being removed.

8:33

And all of this was done without ever thinking about how we were going to implement all of this in the view.

8:38

But, speaking of the view, it’s time to take care of that, and we have 4 different views to update. Let’s do this in order of easiest to hardest since creating the view for this is going to require a lot more work than the timer did. Saved facts in SwiftUI

8:50

So, let’s start with SwiftUI, which of course should be the easiest. First things first, the SwiftUI view isn’t compiling yet because currently we are using the alert view modifier like this: .alert($model.alert)

9:06

This form of the modifier only works when the generic of AlertState is Never since that means there are no actions to handle. But we now do have actions.

9:12

So, we need to provide a trailing closure that is handed the action that is tapped in the alert: .alert($model.alert) { action in }

9:17

And then we have to handle it. But, we implemented a handle method in the model specifically for this, so we can just invoke it: .alert($model.alert) { action in guard let action else { return } model.handle(alertAction: action) }

9:39

And now this view is compiling. However, the UIKit and Epoxy views are not compiling and it’s for the same reason. Luckily the fix is the same, except now we need to provide the UIAlertController initializer a trailing closure that handles the action that is tapped on in the alert: UIAlertController(state: alert) { action in guard let action else { return } self.model.handle(alertAction: action) }

10:15

And now the whole project is back to compiling.

10:18

We are now handling the actions from the alert. All that is left is to do is display the list of facts, which we can do by adding a new section at the bottom of the form: Section { ForEach(model.savedFacts, id: \.self) { fact in HStack { Text(fact) Spacer() Button("Delete") { model.deleteFactButtonTapped(fact: fact) } } } } header: { Text("Favorite facts") }

11:18

And believe it or not, that is all it takes for SwiftUI. We can run the preview and see that it does indeed work. We can fetch a few facts and save them, and then later delete to delete one. It’s incredible to see how a multi-step process for saving and delete alerts was implementing in the model layer in full isolation, and then it took only a moment to add the functionality to the view. Saved facts in Epoxy

12:48

We have now seen again what it looks like to implement a new feature from scratch. We first concentrate on just the domain, where is that observable model that is completely isolated from any view-related concerns. And only when that is done in complete isolation do we start to think about getting the view ready. And this technique works even when dealing with complex multi-step processes, as is the case with saving and deleting facts. Stephen

13:12

Yes, but also the SwiftUI version of this feature is by far the easiest one to implement. Let’s take a look at the other view paradigms, such as Epoxy, UIKit and Wasm. It’s going to take a little more work to implement those views, but also it isn’t so bad.

13:26

Let’s dig it.

13:29

Not all views are going to be so easy, but Epoxy is pretty close to SwiftUI in ease. There are going to be a few more DataID cases we will need to add. We are going to have a row for the header of the “Favorite facts” section, as well as a row for each fact. And those rows are going to need unique IDs, so we will use the act fact string as associated data in the case: private enum DataID { case activity case count case decrementButton case fact case factButton case savedFactsHeader case incrementButton case savedFact(String) case toggleTimerButton }

13:52

And now that we have an enum with associated data we do not get an automatically synthesized Hashable conformance, and so we must add it explicitly: private enum DataID: Hashable { … }

14:06

Then in the items computed property we can add the header if there are any facts saved, which we can do with a simple if syntax thanks to the builder context we are in: if !model.savedFacts.isEmpty { Label.itemModel( dataID: DataID.savedFactsHeader, content: "Saved facts", style: .style(with: .title1) ) }

14:40

We can further render a Label item for each fact in the model’s array by using a simple for … in syntax: for fact in model.savedFacts { Label.itemModel( dataID: DataID.savedFact(fact), content: fact, style: .style(with: .title3) ) }

15:06

This is also possible thanks to the result builder context we are in. Each Label produced in the for loop is automatically appended to the array of items that powers the collection view.

15:19

However, we don’t want to render just a label in each row. We also want a “Delete” button so that the user can delete that fact. There is no immediate way to do this in Epoxy. We need to define our own, new EpoxyableView conformance to represent this.

15:32

We aren’t going to take the time to do this from scratch, and instead we are going to just paste in something we can use: final class SavedFactRow: UIView, @preconcurrency EpoxyableView { init() { super.init(frame: .zero) setUp() } required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } struct Behaviors { var didTapDelete: (() -> Void)? } func setContent(_ content: String, animated _: Bool) { label.text = content } func setBehaviors(_ behaviors: Behaviors?) { didTapDelete = behaviors?.didTapDelete } private let stack = UIStackView() private let label = UILabel() private let button = UIButton(type: .system) private var didTapDelete: (() -> Void)? private func setUp() { layoutMargins = UIEdgeInsets(top: 20, left: 24, bottom: 20, right: 24) backgroundColor = .quaternarySystemFill stack.axis = .horizontal stack.translatesAutoresizingMaskIntoConstraints = false stack.distribution = .equalSpacing stack.addArrangedSubview(label) stack.addArrangedSubview(button) label.font = .preferredFont(forTextStyle: .title3) label.textColor = .black button.tintColor = .systemBlue button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .title3) button.setTitle("Delete", for: .normal) addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor .constraint(equalTo: layoutMarginsGuide.leadingAnchor), stack.topAnchor .constraint(equalTo: layoutMarginsGuide.topAnchor), stack.trailingAnchor .constraint(equalTo: layoutMarginsGuide.trailingAnchor), stack.bottomAnchor .constraint(equalTo: layoutMarginsGuide.bottomAnchor), ]) button.addTarget(self, action: #selector(handleTap), for: .touchUpInside) } @objc private func handleTap() { didTapDelete?() } } This SavedFactRow uses a UIStackView under the hood in order to put a UILabel and UIButton side-by-side.

16:01

We can use this new type of row in the for loop, and in the didTapDelete closure we can call the appropriate method on the mode: for fact in model.savedFacts { SavedFactRow.itemModel( dataID: DataID.savedFact(fact), content: fact, behaviors: SavedFactRow.Behaviors( didTapDelete: { [weak self] in self?.model.deleteFactButtonTapped(fact: fact) } ) ) }

16:52

That’s all it takes and now our Epoxy view is fully functional. We can save any fact we want, and we can remove any fact. Saved facts in UIKit

17:26

OK, it was pretty straightforward to add the new functionality to the Epoxy view. Let’s try out the UIKit view next, which unfortunately will not be as easy.

17:31

Let’s first create a label that will hold the header for the section of saved facts: let savedFactsHeaderLabel = UILabel() savedFactsHeaderLabel.text = "Saved facts" savedFactsHeaderLabel.font = .preferredFont(forTextStyle: .title1)

17:40

As well as a stack view to hold all the saved facts: let savedFactsStack = UIStackView() savedFactsStack.axis = .vertical

17:46

And we will add these views to the bottom of the root stack view that holds all of our other views: let counterStack = UIStackView(arrangedSubviews: [ … savedFactsHeaderLabel, savedFactsStack, ])

17:52

Next we need to observe changes to the model’s savedFacts array so that we can populate the stack with facts. Technically we could add this logic to the main observe we have already defined: observe { [weak self] in guard let self else { return } countLabel.text = "\(model.count)" activityIndicator.isHidden = !model.factIsLoading counter.isEnabled = !model.factIsLoading factButton.isEnabled = !model.factIsLoading toggleTimerButton.setTitle( model.isTimerRunning ? "Stop timer" : "Start timer", for: .normal ) }

18:00

However, currently this observe is doing quite simple things. Just setting text on some labels and buttons, and hiding or disabling controls based on some state.

18:08

The work we need to perform when the savedFacts state changes is going to be a lot more involved. We are going to need to remove all the views from the savedFactsStack and then add back a view for each fact in the array.

18:18

It of course isn’t the most efficient thing in the world to naively remove all views just to add them back. Some kind of rudimentary data source diffing would be better, or even possibly using a collection view. But that would be a lot more work, and we just want to keep things simple right now.

18:31

And so we are going to start up a new observe : observe { [weak self] in guard let self else { return } } This will make it so that only the state accessed in this trailing closure will cause the UI to be updated. That way if some unrelated field is changed it will not cause the stack view to be recomputed all over again needlessly.

18:41

The first thing we can do is show or hide the header based on if there are any facts saved: observe { [weak self] in guard let self else { return } savedFactsHeaderLabel.isHidden = model.savedFacts.isEmpty }

18:55

Next we can remove all of the items in the savedFactsStack : savedFactsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }

19:05

Then we can loop over each saved fact so that we can create and add a view to the stack: for fact in model.savedFacts { }

19:11

And then in this for loop we have to do the work to create and arrange some views. We can create a label and populate its text with the fact: let factLabel = UILabel() factLabel.numberOfLines = 0 factLabel.text = fact

19:13

And we can create a button that when tapped invokes the deleteFactButtonTapped method on the model: let deleteButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in self?.model.deleteFactButtonTapped(fact: fact) }) deleteButton.setTitle("Delete", for: .normal)

19:23

And finally we can create a stack to hold these two views, and add it to the main savedFactsStack : let factStack = UIStackView(arrangedSubviews: [ factLabel, deleteButton, ]) factStack.axis = .horizontal factStack.distribution = .equalSpacing savedFactsStack.addArrangedSubview(factStack)

20:38

And that is all it takes. We now have a fully functional version of this feature working UIKit. It took a bit more work than SwiftUI, and we should probably eventually put in even more work to make our stack view more efficient. But it gets the job done for now.

21:05

And it’s worth mentioning again that because we only accessed the savedFacts state in this observe trailing closure we are subscribed to its changes and only its changes. If anything else in the model is mutated it will not cause this observe closure to be invoked, and so while the work inside it isn’t the most efficient, it at least will not be called unless truly necessary. Saved facts in Wasm

21:46

Let’s move on to the final version of this feature, which is Wasm. But the build is currently broken for the same reason our iOS app was broken when we first added the saved facts functionality, we have an alert helper that needs to be updated to handle its alert actions: alertDialog($model.alert) { action in model.handle(alertAction: action) }

22:50

That fixes things so we can add this new feature to our Wasm app. And amazingly, it can be done in basically the same way as what we did for UIKit. It starts by creating a DOM element to hold the header for the “Favorite facts” section: var factsHeader = document.createElement("h3") factsHeader.innerText = "Favorite facts" _ = document.body.appendChild(factsHeader)

23:23

As well as a DOM element to hold the facts. But this time we are going to use something that is specific to HTML, which is a <table> element: var factsTable = document.createElement("table") _ = document.body.appendChild(factsTable)

23:40

Next we can start observing changes to savedFacts in order to update the UI, and just as with the UIKit version we are going to do this in a separate observe since the work that needs to be performed is a little more intense: observe { } .store(in: &tokens)

23:57

The first thing we can do is hide the header if there are no facts: factsHeader.hidden = .boolean(model.savedFacts.isEmpty)

24:14

Next we can remove any current rows in the table so that we can recreate them. The HTML tag that is used to represent a row in a table is <tr> , so we will just query for all of those tags and remove them: _ = factsTable.querySelectorAll("tr").forEach(JSClosure { arguments in _ = arguments.first?.remove() return .undefined })

25:08

Next we will loop through all the facts so that we can create HTML elements for them and add it to the table: for fact in model.savedFacts { }

25:18

In here we can start by creating a <tr> element that represents the row and adding it to the table: var row = document.createElement("tr") _ = factsTable.appendChild(row)

25:35

Then we can create a column that represents the fact and add it to the row: var factColumn = document.createElement("td") _ = row.appendChild(factColumn) factColumn.innerText = .string(fact)

25:59

As well as a column that will hold the delete button: var deleteColumn = document.createElement("td") _ = row.appendChild(deleteColumn)

26:06

And finally we can create a button for “Delete” and add it to the column element: var deleteButton = document.createElement("button") _ = deleteColumn.appendChild(deleteButton) deleteButton.innerText = "Delete" deleteButton.onclick = .object( JSClosure { _ in model.deleteFactButtonTapped(fact: fact) return .undefined } )

26:36

It may be hard to believe, but that is all it takes. Our Wasm app is now fully functional. We can save all of our favorite facts, and we can delete any that are no longer our favorites.

27:08

And it’s pretty amazing to see just how similar the Wasm code is to our UIKit code. Line-for-line there is a direct analogy between how we create and set up views in UIKit with how things are done in Wasm. This goes to show that building a Wasm app may not really be that much more complicated than building a UIKit app. Persisting saved facts

27:58

We have now shown how active cross-platform development can happen. We started with an idea of some new functionality we wanted to add to our feature, that of being able to save our favorite facts and being able to remove ones that are no longer our favorites. With that abstract idea in our head we updated the CounterModel to support this functionality. We added new state and new methods to the model, and then implemented the logic, including a multi-step process for confirming the deletion of facts. Brandon

28:22

And then with that bit of abstract work completed we turned to the view side of things, and we had 4 completely different views to update: SwiftUI, Epoxy, UIKit and Wasm. All views are quite different from each other, but we were able to update them very quickly and everything worked right away. We are now sharing a sizable amount of code between multiple view paradigms and multiple platforms, and this has all been possible thanks to a few key components:

28:49

First, Swift’s amazing Observation tools and our powerful Swift Navigation library. This gives us a single consistent API for observation and navigation across all platforms. Stephen

29:02

And second, our insistence to not cram logic directly in the view, which is not cross-platform friendly at all, and our insistence to keep view-related concerns out of the core domain model. That makes it that much easier to share this code across platforms. Brandon

29:20

But there’s one thing I don’t like about what we have accomplished so far. If I refresh the browser or quit and relaunch the app I lose all of my favorites! It would be a much better experience if we persisted the saved facts across runs of the app, whether that be on an iOS device or in a browser.

29:39

We of course have plenty of tools to accomplish this with Apple’s frameworks, but we need to be able to implement this feature in a way that also plays nicely with Wasm and the browser.

29:49

Let’s dig in.

29:53

Since we are all probably already quite familiar with the various persistence APIs on Apple’s platforms, let’s first explore what is available to us in the browser. The simplest storage API that JavaScript provides is known as “local storage”, and it is a simple key-value store and it is similar to Apple’s UserDefaults API, though a bit more restricted.

30:16

Let’s open up the console in the browser again to explore this API. One accesses the localStorage object through the window global: window.localStorage

30:26

This object has a few methods, such as setItem , which allows you to set a value for a key: window.localStorage.setItem("message", "Hello!")

30:42

And then you can retrieve the item with the getItem method: window.localStorage.getItem("message") // "Hello!"

30:51

And this data persists across refreshes and launches of the browser. If I refresh, and then get the item again, I get the same string: window.localStorage.getItem("message") // "Hello!"

31:06

So this seems like a promising way to persist data on the browser. And then for Apple platforms we can use the standard APIs for saving and loading data to disk.

31:16

Let’s now try adding this functionality to our feature. This functionality belongs purely in the CounterModel domain, and doesn’t even have a view-layer side of things. We should be able to just add some logic to the model and then have everything automatically work on all platforms.

31:37

We are also going to do this the right way from the very beginning. This means we are going to properly design this as a dependency that can be used by the model, and we are further going to separate the interface from the implementation.

31:48

So, in the Package.swift we will add two new libraries, one for the client interface and one for the live implementation: .library( name: "StorageClient", targets: ["StorageClient"] ), .library( name: "StorageClientLive", targets: ["StorageClientLive"] ),

31:58

Then we will add two new targets for each of these libraries, and the live implementation will further depend on some WASI libraries, but only when building for WASI: .target( name: "StorageClient", dependencies: [ .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), ] ), .target( name: "StorageClientLive", dependencies: [ "StorageClient", .product(name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), ] )

32:23

Note that this time we do not need the JavaScriptEventLoop library for the live implementation because we are not making any network requests. We don’t need an event loop in order to access local storage.

32:52

Next we will sketch out an interface for the storage client. It will have an endpoint for loading data from storage with a given key: import Foundation public struct StorageClient { public var load: (String) throws -> Data }

33:25

And it will have an endpoint for saving data to that storage: public var save: (Data, String) throws -> Void

33:36

This is a very abstract interface to interacting with some storage system, and we should be able to write implementations for this interface that use Apple’s APIs when deploying to Apple platforms, and use JavaScript APIs when deploying to Wasm.

33:50

We are further going to apply the @DependencyClient macro: import DependenciesMacros @DependencyClient public struct StorageClient { … }

33:56

…which gives us a number of benefits. First of all it immediately gives us an implementation that is appropriate to use as the testValue of the TestDependencyKey conformance: import Dependencies @DependencyClient public struct StorageClient: TestDependencyKey { … public static let testValue = StorageClient() }

34:28

This is a special implementation that simply throws an error if the load or save endpoint are invoked, and further reports an issue, which will cause a test failure during a testing context. That helps you prove that you know exactly when parts of the dependency are being used during tests. That is all it takes to define our dependency’s interface. Next we need to define its implementation.

35:16

We can create a new file in the StorageClientLive module for this…

35:25

And we will want to extend the StorageClient type to conform to the DependencyKey protocol: import Dependencies import StorageClient extension StorageClient: DependencyKey { }

35:35

And to conform to this protocol we need to provide a liveValue implementation that is used when running the app in a live context, such as the simulator or browser: public static let liveValue = Self( load: { key in }, save: { data, key in } )

36:05

But these implementations are going to be wildly different on different platforms, so let’s separate them based on platform: #if os(WASI) public static let liveValue = Self( load: { key in }, save: { data, key in } ) #else public static let liveValue = Self( load: { }, save: { data, key in } ) #endif

36:35

Let’s start with the Apple platform since that’s what we are most used to dealing with.

36:41

The load endpoint can try loading the data from disk using the Data(contentsOf:) initializer: load: { key in let url = URL.documentsDirectory .appendingPathComponent(key) .appendingPathExtension("json") return try Data(contentsOf: URL(fileURLWithPath: url) },

37:44

We’re getting closer but we do have a compile error that liveValue isn’t concurrency safe because StorageClient is not Sendable , and it should be, so let’s update it: @DependencyClient public struct StorageClient: TestDependencyKey, Sendable { public var load: @Sendable (String) throws -> Data public var save: @Sendable (Data, String) throws -> Void … }

38:10

And now that the compiler is happy again, the save endpoint can be implemented by calling out to the write(to:) method: save: { data, key in let url = URL.documentsDirectory .appendingPathComponent(key) .appendingPathExtension("json") try data.write(to: url) }

38:29

That’s all pretty straightforward.

38:32

Things are a little more involved to build this client for Wasm. First let’s import JavaScriptKit when we are compiling for Wasm: #if os(WASI) import JavaScriptKit #endif

38:47

Then the load endpoint can be implemented by invoking the getItem API on localStorage , and if no item can be loaded we will throw an error: load: { key in guard let value = JSObject.global.window.localStorage.getItem(key).string else { struct DataLoadingError: Error {} throw DataLoadingError() } return Data(value.utf8) },

39:45

And the save endpoint can be implemented by invoking setItem : save: { data, key in JSObject.global.window.localStorage .setItem(key, String(decoding: data, as: UTF8.self)) }

40:14

And that’s all it takes to implement this dependency for both Apple platforms and the Wasm platform.

40:20

Next let’s start using it in our counter feature. To do this we need to depend on the StorageClient module : .target( name: "Counter", dependencies: [ "FactClient", "StorageClient", .product(name: "SwiftNavigation", package: "swift-navigation"), .product(name: "Perception", package: "swift-perception") ] ),

40:33

It’s important to note that we are not depending on the implementation at all here. We are only depending on the interface, which has no dependence on any of the Wasm libraries.

40:48

Next we can add the dependency client to our CounterModel class: @MainActor @Perceptible public class CounterModel: HashableObject { … @PerceptionIgnored @Dependency(StorageClient.self) var storageClient … }

41:07

And then when the model is initialized we can load the current collection of favorite facts from storage: public init() { savedFacts = try JSONDecoder().decode( [String].self, from: storageClient.load("saved-facts") ) }

41:35

But we’re not in a throwing context, so let’s add a do – catch block: public init() { do { savedFacts = try JSONDecoder().decode( [String].self, from: storageClient.load("saved-facts") ) } catch { // TODO: handle error } }

42:24

And finally, when the savedFacts state is mutated we will use the storage client to save the data: public var savedFacts: [String] = [] { didSet { do { try storageClient.save( JSONEncoder().encode(savedFacts), "saved-facts" ) } catch { // TODO: handle error } } }

43:16

And that is all it takes to integrate the storage dependency into our counter feature.

43:22

Now one thing that isn’t great is the inline, repeated “saved-facts” string key. We can at least extract it to a static so they never get out of sync: fileprivate extension String { static let savedFactsKey = "saved-facts" } … try storageClient.save( JSONEncoder().encode(savedFacts), .savedFactsKey ) … savedFacts = try JSONDecoder().decode( [String].self, from: storageClient.load(.savedFactsKey) )

44:08

However, before moving on there is something we can do to improve how we invoke the save endpoint. One unfortunate aspect to the struct-style of dependencies is that we lose argument labels: try storageClient.save(JSONEncoder().encode(savedFacts), .favoriteFacts)

44:57

However, our @DependencyClient macro can help here. If we give an argument label to the closure arguments: public var save: @Sendable (Data, _ to: URL) throws -> Void

45:35

When this is done the @DependencyClient will generate a method with argument labels for you so that you can use a friendlier syntax: try storageClient.save( JSONEncoder().encode(savedFacts), to: .savedFactsKey )

46:23

This is a nice way to be able to recover some of the ergonomics of protocols while also preserving the benefits of struct-style dependencies.

46:32

Ideally we should be able to run the app in the browser or similar and everything should work.

46:42

However, if we run the app in the browser, we immediately have a warning printed to the console letting us know something isn’t quite right: Counter/CounterModel.swift:16 - @Dependency(StorageClient.self) has no live implementation, but was accessed from a live context.

46:52

This is exactly what happened when we introduced our FactClient dependency. We have to be sure to link the live implementation of the dependency with our final app targets in order for it to be used. It’s great that this warning keeps us in check, and that it works in basically the same way whether we are deploying our app in a browser or on an iOS device.

47:12

So, let’s make sure to link StorageClientLive with our executable: .executableTarget( name: "WasmApp", dependencies: [ "Counter", "FactClientLive", "StorageClientLive", .product(name: "SwiftNavigation", package: "swift-navigation"), .product(name: "JavaScriptEventLoop", package: "JavaScriptKit"), .product(name: "JavaScriptKit", package: "JavaScriptKit"), ] ),

47:53

And now everything works beautiful. We can save as many facts as we want, and when we refresh the browser all the state is restored.

49:17

Let’s check out the iOS app to see if it works. We can run the app in the simulator to see that we are immediately met with a runtime warning, just as we saw in the Wasm app: Counter/CounterModel.swift:16 - @Dependency(StorageClient.self) has no live implementation, but was accessed from a live context.

49:44

Again the problem here is that we have not linked the live implementation of the StorageClient with our app, and so it has no choice but to use the test implementation, which of course is not the right thing to do. So, let’s explicitly link against the StorageClientLive module in our app target.

49:54

And now when we run the app it behaves exactly as we expect. We can save a bunch of facts, re-run the app, and we will see all the facts are there. Conclusion

50:41

And this brings us to the conclusion of our multi-part series exploring cross-platform Swift. We have built a moderately complex app, involving state observation, UI control bindings and focus, network requests, navigation, timers and even persistence.

50:57

The core of the feature was built in 100% pure Swift, which means that code can be compiled on any platform supported by Swift, such as Windows, Linux, Wasm, and of course iOS and macOS. This this was all possible due to 2 important principles we upheld: we didn’t put our feature’s logic in the view and never put view-related concerns in our model, and on top of that we controlled our dependencies so that it was easy to implement things like network requests and persistence in a platform specific manner. Stephen

51:27

And then with that core model built in isolation, it was very straightforward to build out the view that is powered by the model. In fact, we did so 4 different times!

51:37

We built the view in SwiftUI, which of course was the easiest.

51:40

We also built the view in UIKit, which was a bit more work but ultimately pretty straightforward.

51:45

Then we built in the view in Epoxy, a third party library that aims to make building lists of views a bit easier. This was definitely easier to do than in UIKit.

51:54

And then finally we built a view in

HTML 52:04

And shockingly, the core of how we built these 4 different views looked very similar, even in SwiftUI. To observe state changes we used the observe function that allows us to automatically observe changes to any field accessed in the view. SwiftUI didn’t need to use this tool because it happens implicitly inside the body of any SwiftUI view. And we used various navigation methods that took bindings in order to drive navigation. These tools looked identical across all 4 views, from SwiftUI to Wasm. It is absolutely incredible. Stephen

HTML 52:44

We personally think cross-platform Swift is quite exciting, and we hope everyone plays around with it more. The more attention it gets, the more likely the various bugs in Xcode and the toolchains will be fixed.

HTML 52:55

That’s it for this series, until next time! Downloads Sample code 0296-cross-platform-pt7 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .