EP 273 · Shared State · Apr 1, 2024 ·Members

Video #273: Shared State: User Defaults, Part 1

smart_display

Loading stream…

Video #273: Shared State: User Defaults, Part 1

Episode: Video #273 Date: Apr 1, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep273-shared-state-ubiquity-persistence

Episode thumbnail

Description

Let’s enhance the @Shared property wrapper with the concept of persistence. We will begin with user defaults, which is the simplest form of persistence on Apple’s platforms, and that will set the stage for more complex forms of persistence in the future.

Video

Cloudflare Stream video ID: 18bc14f14636666929fe0c6e13d6d739 Local file: video_273_shared-state-ubiquity-persistence.mp4 *(download with --video 273)*

Transcript

0:05

OK, this is all looking absolutely incredible. We have now seemingly fixed all of the problems we encountered with sharing state, and so we can now full heartedly and strongly recommend representing simple shared state in your applications as a reference type. Historically it would have been quite problematic to put a reference in a Composable Architecture feature, for two main reasons:

0:25

Reference types used to not play nicely with view invalidation, and so you could make changes to the data in the reference and that would not cause the view to re-render. That is no longer a concern thanks to Swift’s new observation tools. Brandon

0:38

And second, reference types are not easy to test and debug since you can’t capture the before and after values to compare. But we have now fixed that thanks to the Shared type and some new internal logic inside the TestStore .

0:53

Now everything we have accomplished so far is fantastic, and we could stop here and have a very compelling story for how to share state amongst features in the Composable Architecture. But we can make things even better. Stephen

1:06

Sometimes we want shared state to be very explicit and localized. This is how our case study is structured right now, and how we approached the complex sign up flow a few episodes back. If a feature wants a piece of shared state, it must use the @Shared property wrapper, and whoever creates that feature must pass along a piece of shared state.

1:23

But sometimes we want shared state to be ubiquitous throughout the application. Any feature should be able to reach out and grab the shared state immediately without it being passed around explicitly, and should be able to make changes to that shared state. Brandon

1:39

The prototypical example of this is settings. Settings is usually state that the entire app needs to be able to access, and that perhaps a few features also need to be able to write to. If we stopped with shared state as it is right now we would have to explicitly pass around Shared values to every feature that needs settings. And this is a viral situation. If some deep leaf feature needs access to settings, then every parent feature needs to also hold onto a Shared settings object just to pass it along, even if it doesn’t care about settings.

2:10

Let’s see what it takes share state across the entire application, instantly . Ubiquitous shared state

2:16

Let’s take a look at the shared state case study again to see what can be improved about it. In particular, this initializer of the State struct: init( currentTab: Tab = Tab.counter, stats: Shared<Stats> ) { self.currentTab = currentTab self._stats = stats self.counter = CounterTab.State(stats: stats) self.profile = ProfileTab.State(stats: stats) }

2:36

Here we have to perform a delicate maneuver to take stats passed in from the parent and hand it over to each child feature. There’s a lot of things we could have gotten wrong here, such as if we had accidentally created separate shared values to hand to each feature: init( currentTab: Tab = Tab.counter stats: Shared<Stats> ) { self.currentTab = currentTab self._stats = stats self.counter = CounterTab.State(stats: Shared(Stats())) self.profile = ProfileTab.State(stats: Shared(Stats())) }

2:54

Seems a little silly in isolation, but in a very large, complex app it may be easy to accidentally make this mistake. And that would mean these features aren’t sharing their data at all. Each has a completely independent version of the stats.

3:09

And so in some situations it may be better to model this shared data as being ubiquitous. That is, it’s not something that needs to be passed around to each layer, but rather something that should always be available to pluck out of thin air in order to access and mutate.

3:26

Now we want the current syntax for @Shared will continue working in the same way: @Shared var stats: Stats

3:34

When we see this we should know that we are holding onto some shared data, and the source of truth of this state is going to be passed in to us from the parent.

3:39

And then to support ubiquitous shared state we will require the user provide a key in order to locate the piece of a shared state in some global storage of shared state. Right now we can just use a string key: @Shared("stats") var stats: Stats

4:06

But this isn’t going to be quite right. We cannot leave this property uninitialized because what if we try to fetch the value from some global store and there is no value present? We need to have some default to use in that case, which we can do with an initial value: @Shared("stats") var stats = Stats()

4:34

This is similar to how things like @AppStorage work in vanilla SwiftUI: @AppStorage("isOn") var isOn = false

4:47

You must provide a default just in case there is nothing in user defaults yet and you have to seed it with a value: @AppStorage("isOn") var isOn: Bool

5:00

We now have a syntax that we would like to use, so let’s make it a reality.

5:06

We can start by adding a new initializer that takes a wrapped value and a string key: public init(wrappedValue value: Value, _ key: String) { self.currentValue = value }

5:58

That alone is enough to get the syntax we theorized a moment ago to compile.

6:03

And we can start making use of it, like in the CounterTab feature to represent that there is shared state that will be globally available: @Reducer struct CounterTab { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Shared("stats") var stats = Stats() } … }

6:30

And we can do the same for the profile feature… @Reducer struct ProfileTab { @ObservableState struct State: Equatable { @Shared("stats") var stats = Stats() } … }

6:40

And these features are already compiling. But where we construct the features’ states we have compilation errors: self.counter = CounterTab.State(stats: stats) self.profile = CounterTab.State(stats: stats)

6:45

And this is because we no longer need to pass along the shared stats: self.counter = CounterTab.State() self.profile = CounterTab.State()

6:52

And we can update the root feature to also grab the globally shared state, and now we no longer need to explicitly pass it to the parent or child features: @Reducer struct SharedState { enum Tab { case counter, profile } @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter: CounterTab.State var profile: ProfileTab.State @Shared("stats") var stats = Stats() init( currentTab: Tab = Tab.counter ) { self.currentTab = currentTab self.counter = CounterTab.State() self.profile = ProfileTab.State() } } … }

6:58

We can even get rid of the custom initializer now, since we no longer have to manually propagate the shared value to child features and can instead initialize those features inline. @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter = CounterTab.State() var profile = ProfileTab.State() @Shared("stats") var stats = Stats() }

7:14

And this is great because ideally we would like the CounterTab feature to grab the global stats from thin air rather than having it passed explicitly.

7:28

Everything is now compiling, but of course we haven’t actually accomplished much. We aren’t doing anything to store the stats in a global storage or fetch it from the storage, so there is no actual sharing of state.

7:37

On approach to this could be to have an actual global dictionary of state that maps string keys to any value: private var sharedStates: [String: Any] = [:]

7:57

And then when initializing the @Shared property wrapper we can check the global storage to see if there’s a value, and if so we can discard the value given to us and take the one from storage: public init(wrappedValue value: Value, _ key: String) { if let value = sharedStates[key] as? Value { self.currentValue = value } else { self.currentValue = value } }

8:26

That certainly seems OK for initializing shared state. We are now getting the value from the global storage to seed the initial state.

8:31

But what about when we get the wrapped value from the property wrapper: get { if SharedLocals.isAsserting { return self.snapshot ?? self.currentValue } else { return self.currentValue } } And set the value: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { if self.snapshot == nil { self.snapshot = self.currentValue } self.currentValue = newValue SharedLocals.changeTracker() } }

8:37

All of this code is accessing the local currentValue that is stored inside the property wrapper, but that is completely untethered to the global store we defined a moment ago.

8:50

We would have to do extra work to pull data from the storage and keep it in sync with the property wrapper. That sounds like a pain, and we turned to reference types for shared storage specifically to get way from having to manually sync data between multiple places.

9:05

The reason we are having this problem is because we are storing the value in the global storage, but what we want to do is store the reference . That would allow us to retrieve the reference to read from it and write to it, and most importantly anyone else holding the reference will instantly see the newest value. No need for synchronization.

9:28

So what we’d like to do is upgrade the global storage to hold onto objects instead of any kind of value: private var sharedStates: [String: AnyObject] = [:]

9:32

And then perhaps in the initializer when we retrieve a shared value from the storage we can assign it to self if successful: public init(wrappedValue value: Value, _ key: String) { if let shared = sharedStates[key] as? Shared<Value> { self = shared } else { self.currentValue = value } }

9:48

But unfortunately this does not work. You cannot re-assign self in classes: Cannot assign to value: ‘self’ is immutable

9:54

This is possible for structs, but not classes.

9:58

So, we actually need to refactor Shared to be a struct, but it can hold onto a reference on the inside. This will give us the ability to hold onto the reference in the global storage and re-assign it inside the struct.

10:15

And in fact, this trick of a struct on the outside with a class on the inside is how the @State property works in SwiftUI: @State var count = 0

10:32

We can command click on @State to jump to a header file and see that it is defined as a struct: @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) @frozen @propertyWrapper public struct State<Value>: DynamicProperty { … }

10:41

However, it is definitely not just a plain value type. There is definitely some reference-y stuff happening on the inside.

10:50

To begin with, the wrappedValue of @State has a non-mutating setter: public var wrappedValue: Value { get nonmutating set }

10:59

That is the most clear sign you can have that a struct is not being truthful. It has a property that is capable of being set, yet somehow setting the value does not mutate the value. The only way it can do this is if its mutating a reference on the inside.

11:13

And we can see this weirdness plain as day by binding a State value with let , and then mutating it: import SwiftUI func test() { let state = State(initialValue: 1) state.wrappedValue += 1 }

11:32

This typically would not compile because we are using let and because State is a struct. But it is compiling, and it’s because secretly @State is holding onto a reference under the hood, and when we mutate the wrappedValue we are actually just mutating the reference, which does not count as a mutating to the State value.

11:59

We need to mimic what @State does for our @Shared property wrapper. It needs to be a struct on the outside, but a reference on the inside, and that reference is what will be stored in the global storage.

12:13

So, let’s make Shared into a struct: @Perceptible @propertyWrapper public struct Shared<Value> { … }

12:18

And the @Perceptible property wrapper cannot be applied to structs, but we don’t actually need observation at this level. We’ll handle it on the reference inside, so let’s remove it for now: @propertyWrapper public struct Shared<Value> { … }

12:32

Next we will define a reference nested inside the struct type that holds onto the actual shared value: @propertyWrapper public struct Shared<Value> { … @Perceptible final class Storage { var value: Value init(value: Value) { self.value = value } } }

12:52

And then instead of holding onto a currentValue and snapshot directly in Shared we will hold onto Storage : @propertyWrapper struct Shared<Value> { let storage: Storage … }

13:07

But we still need to represent the snapshot somewhere, and that should go in the storage: @Perceptible final class Storage { private var snapshot: Value? … }

13:26

And we need to do the trick of holding onto the current value separately from the value that is accessed from the outside so that we can manage the snapshot whenever writing to the value: @Perceptible final class Storage { var value: Value { get { } set { } } private var currentValue: Value private var snapshot: Value? … }

13:53

And in the get and set of this outward facing property we will do the tricks to keep track of the snapshot: @Perceptible class Storage { var value: Value { get { if SharedLocals.isAsserting { return self.snapshot ?? self.currentValue } else { return self.currentValue } } set { if SharedLocals.isAsserting { self.snapshot = newValue } else { if self.snapshot == nil { self.snapshot = self.currentValue } self.currentValue = newValue SharedLocals.changeTracker() } } } private var currentValue: Value private var snapshot: Value? init(value: Value) { self.currentValue = value } }

14:00

With that done we can make the wrappedValue of the @State property wrapper reach into the storage: public var wrappedValue: Value { get { self.storage.value } set { self.storage.value = newValue } }

14:22

Then we can update the initializer on Shared that does not take a key by simply wrapping the value in a Storage reference: public init(_ value: Value) { self.storage = Storage(value: value) }

14:40

And we can update the initializer that takes a key to first check if there is something in the global storage, and if so we put that reference into the struct, and otherwise we create new storage and put it in the global store: public init(wrappedValue value: Value, _ key: String) { if let storage = sharedStates[key] as? Storage { self.storage = storage } else { self.storage = Storage(value: value) sharedStates[key] = self.storage } }

15:20

There are still a few compiler errors to fix. First, the conditional Equatable conformance will be moved to Shared.Storage : extension Shared.Storage: Equatable where Value: Equatable { static func == ( lhs: Shared.Storage, rhs: Shared.Storage ) -> Bool { if SharedLocals.isAsserting { return lhs.snapshot ?? lhs.currentValue == rhs.currentValue } else { return lhs.currentValue == rhs.currentValue } } }

15:42

And then we will make Shared into Equatable by delegating down to the storage: extension Shared: Equatable where Value: Equatable {}

15:53

We will also move the _CustomDiffObject conformance to the storage: extension Shared.Storage: _CustomDiffObject { public var _customDiffValues: (Any, Any) { (self.snapshot ?? self.currentValue, self.currentValue) } }

16:00

And that is all it takes to get things building again, and the app works exactly as it did before, but we now have ubiquitous access to the stats state. We do not have to pass it around explicitly, and instead any feature can simply pluck it out of thin air.

17:04

And even better, this is all still testable.

17:12

If we run the test suite it passes. But, if we assert something incorrectly: await store.send(.selectTab(.profile)) { $0.currentTab = .profile // $0.stats.increment() }

17:28

…we instantly get a test failure: A state change does not match expectation: … SharedState.State( _currentTab: .profile, _counter: CounterTab.State( _alert: nil, _stats: Shared( storage: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ) ), _profile: ProfileTab.State( _stats: Shared( storage: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ) ), _stats: Shared( storage: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ) ) (Expected: −, Actual: +)

17:31

This let’s us know exactly what went wrong, though maybe it’s also letting us know too much. It’s leaking some details of the Shared type, which we can avoid by telling Custom Dump to dump the value directly instead: extension Shared: CustomDumpRepresentable { public var customDumpValue: Any { self.storage } }

18:38

And now the failure is even better: A state change does not match expectation: … SharedState.State( _currentTab: .profile, _counter: CounterTab.State( _alert: nil, _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ), _profile: ProfileTab.State( _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ), _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ) (Expected: −, Actual: +)

18:48

But let’s get the test passing again… Persistence to user defaults

18:56

We now have two flavors of shared state. We have “local” shared state that can be explicitly passed around to features that need it, and this is done using the plain, unadorned @Shared property wrapper.

19:10

And we have “ubiquitous” shared state, whereby specifying a key we can globally share the state throughout the entire application so that any feature can reach out and grab the state at anytime. And all features accessing the state will always see the freshest data and always be in sync.

19:29

And still it’s all 100% testable, and even exhaustively testable. We are forced to assert on everything happening in this shared reference, and if we don’t we get a test failure. Stephen

19:40

This ubiquitous state is also hinting at something much bigger. Ubiquity is great for sharing state throughout the entire application, but many times we want to further persist that data to some external system. Perhaps the most prototypical example of this in the Apple ecosystem is user defaults, which allows you to persist simple data types to a storage system specific to your app.

20:01

Another example is the file system, which works better for larger and more complex data structures that you want to persist. You would usually use some simple serialization format for this, like JSON or XML.

20:12

But there are many other kinds of persistence that our viewers may be interested in. For example, they may want to persist to a SQLite database stored on the device. Or they may want to use SQLite through a wrapper, such as GRDB. Or they may want to persist directly to an external database through a REST API. Or, perhaps the most sought after persistence strategy, they may want to use CoreData.

20:32

And we can even think of the ubiquitously shared state that we just implemented as being a kind of “in memory” persistence strategy. It’s just a persistence strategy that clears out every single time you restart the app, much like how CoreData’s “in memory” persistence store works.

20:47

And so wouldn’t it be cool if we could abstract away the notion of persistence and integrate it into the @Shared property wrapper so that it just works seamlessly without us ever having to think about persistence directly in our feature’s logic and behavior?

21:01

And that absolutely is possible. Let’s take a look.

21:07

Let’s take a look at the @AppStorage property wrapper that comes with SwiftUI for inspiration of how we want persistence to work in the Composable Architecture. It’s a super easy and lightweight way to add a little bit of persisted data to any view, and behind the scenes it is backed by user defaults.

21:21

It can be used by just specifying the key where you want the data to live in user defaults, as well as the default value to use when there is no value in the defaults: struct CounterTabView: View { @Bindable var store: StoreOf<CounterTab> @AppStorage("isOn") var isOn = false … }

21:49

So, this state starts as false , but as soon as we mutate it the key will be updated in user defaults, and therefore automatically persisted across app launches.

21:58

To see this we can add a toggle to the view for flipping this boolean on and off, and thanks to how the @AppStorage property wrapper works we can even easily derive a binding to the state: Section { Toggle(isOn: $isOn) { Text("@AppStorage isOn: \(isOn.description)") } }

22:29

If we run this in the simulator or preview we will see that it seems to behave just as a normal toggle. But, if we toggle it on, and then relaunch the simulator or preview, we will see that the view launches with the toggle already on. This happens because when we flipped isOn to true, that boolean value was written to user defaults, and hence persisted, and so next launch the value was resurrected from the user defaults storage rather than taking the false default we provided.

23:03

That’s pretty cool, and it’s great to see how easy it is to add little bits of persisted state to a view. However, there are drawbacks to this style of data persistence. For one thing, this property wrapper only works when installed in a view. It does not work if you use the @AppStorage anywhere else, such as in an observable object. To see this let’s create a very simple class that holds onto some app storage and is annotated with the @Observable macro: @Observable class CounterModel { @ObservationIgnored @AppStorage("isOn") var isOn = false }

23:36

Note that we have to mark the isOn property with @ObservationIgnored because property wrappers do not work with the @Observable macro. And so that is seeming not so great.

23:42

Then we can introduce the model to our view: struct CounterTabView: View { @State var model = CounterModel() … }

23:53

And add a toggle to the view for flipping that bit of state inside the model from off to on and vice-versa: Toggle(isOn: $model.isOn) { Text( """ @Observable @AppStorage isOn: \(model.isOn.description) """ ) }

24:10

If we run this in the preview and tap this new toggle, we will see that it seems to work. Flipping it to on causes the other toggle to flip on, and flipping it off causes the other toggle to flip off.

24:20

However, what doesn’t work is when you flip the other toggle. The one that mutates the @AppStorage held directly in the view. Flipping that toggle seems to have no effect whatsoever on the other toggle. It also doesn’t work to tap the “Toggle user defaults directly” button either. That is setting the user defaults directly under the hood, but that does not cause the toggle to magically update.

24:28

And this isn’t an iOS 17 observation bug either. Had we used the older style model using the ObservableObject protocol and @ObservedObject property wrapper it would have behaved the same. The sad truth is that there are a lot of very powerful property wrappers in SwiftUI that only work when installed directly in the view. They cannot be used anywhere else. This means that they are very difficult to test, if not down right impossible.

24:55

Also there are some projects out there that aim to fill this gap between observable models and app storage by supplying another macro that can be used with the @Observable . Unfortunately we have found these projects have some serious problems of their own.

25:10

For example, when @AppStorage is installed directly in a view it will listen for external changes to the “isOn” key in the user defaults so that it can properly update itself if it detects a change. To see this, let’s add a button to the view that bypasses @AppStorage entirely and just writes to the user defaults directly: Button("Toggle user defaults directly") { UserDefaults.standard.setValue( !UserDefaults.standard.bool(forKey: "isOn"), forKey: "isOn" ) }

25:54

You would hope that tapping this button causes the toggle to flip on and off, and it does! That’s all thanks to the @AppStorage property wrapper doing extra work for us so that we don’t have to think about it, and that keeps our UI up-to-date and always representing the true current state of the system.

26:13

The second toggle also appears to work, but it’s just by accident. The first toggle observes the change to user defaults and causes the view to re-draw, and so that causes the second toggle to update.

26:26

If we were to comment out the first toggle: // Toggle(isOn: $isOn) { // Text("@AppStorage isOn: \(isOn.description)") // }

26:31

…we would see that the remaining toggle no longer updates when we write to user defaults directly.

26:42

And this is the problem we have seen in most of the solutions out there that try to bridge @AppStorage to other places outside views. They don’t take into consideration that user defaults can be written to from the outside and that it must be properly observed, and so we do not recommend using those libraries, or at least being aware of their shortcomings.

27:01

Now that we understand how SwiftUI’s tool for interfacing with user defaults works, let’s imagine how we want our tool to work. I think this tool should still be built on the foundation already set by the @Shared property wrapper, but perhaps there is some additional configuration we can provide @Shared when declaring it: @ObservableState struct State: Equatable { @Shared(.appStorage("isOn")) var isOn = false … }

27:41

Somehow this would communicate to the @Shared property wrapper that we want to use a persistence strategy behind the scenes to load the value from user defaults and any changes to the value should be persisted to user defaults. This would give us a lightweight way to persist simple data types so that they are immediately available upon relaunching the app. And even better if we can still make this play nicely with testing.

28:02

So, let’s see what it takes to make this syntax a reality.

28:05

Let’s start by defining an abstraction for what it means to be able to persist and load a value from an external system. So far we have two use cases we want to support: “in memory” and user defaults. And in the future we will have file persistence, and perhaps even SQLite persistence, CoreData persistence, and more.

28:21

The most basic version of such an abstraction would just be a protocol that with endpoint points for loading a value and saving a value. We’ll call it PersistenceKey since it will also be used to look up the shared reference: public protocol PersistenceKey { associatedtype Value func load() -> Value? func save(_ value: Value) }

28:57

The load returns an optional because there’s a chance there is no value to load in the external system, such as on the first launch of the application.

29:04

With that defined, before even trying to use it in the Shared type we can already implement a few conformances. For example, the “in memory” style of persistence is one that doesn’t actually persist. It can only keep the value in memory and can never save it to an external system or load it from an external system: struct InMemoryKey<Value>: PersistenceKey { let key: String func load() -> Value? { nil } func save(_ value: Value) {} }

29:51

Looks silly, but is totally legitimate.

29:55

We can also make a AppStorageKey that delegates all loading and saving to UserDefaults.standard , given some key: import Foundation struct AppStorageKey<Value>: PersistenceKey { let key: String func load() -> Value? { UserDefaults.standard.value(forKey: self.key) as? Value } func save(_ value: Value) { UserDefaults.standard.setValue(value, forKey: key) } }

30:45

This is quite simple, but also probably a little too simple for user defaults storage. User defaults is not capable of storing any kind of data. It is limited to very basic data types, such as strings, booleans, integers, and a few more.

30:59

Right now we are allowing one to save and load any kind of value, and if you use something not supported it will crash at runtime. If we go to the documentation for the @AppStorage property wrapper we will see a whole bunch of initializers which prevent you from trying to store something in user defaults that won’t work. But we aren’t going to worry about any of that now.

31:35

So we now have two persistence strategies implemented, but how can we use them?

31:39

Well, currently we are keeping track of the ubiquitous shared references in a global dictionary keyed off a simple string: private var sharedStates: [String: AnyObject] = [:]

31:46

The string is what is provided to the @Shared property wrapper, and is what is used to look up the global reference that is shared across the entire app.

31:53

We need to update this now to not be keyed off a simple string, but instead it can be keyed off an entire persistence strategy: private var sharedStates: [any PersistenceKey: AnyObject] = [:]

32:05

That way both InMemoryKey and AppStorageKey can both be stored in this global dictionary.

32:09

But this doesn’t work for a few reasons. First, it is not known that the persistence strategy is Hashable , so we should require it on the protocol: public protocol PersistenceKey: Hashable { … }

32:21

And the InMemoryKey and AppStorageKey conformances are already Hashable , so there’s nothing extra that needs to be done there.

32:28

But even with that done the dictionary still does not compile because although conformances of the PersistenceKey protocol must also conform to Hashable , it is not true that the existential any PersistenceKey conforms to Hashable .

32:40

But, we don’t actually need any concrete persistence information in the key used in the dictionary. We can erase all of that away and only leave behind its hashable-ness: private var sharedStates: [AnyHashable: AnyObject] = [:]

32:50

And now that compiles.

32:52

We can update the @Shared initializer for the ubiquitous style of shared state. We can mostly copy and paste the current initializer that takes a string, make a few changes, and then delegate the string key initializer to the new persistence key one: public init(wrappedValue value: Value, _ key: String) { self.init(wrappedValue: value, InMemoryKey<Value>(key: key)) } public init(wrappedValue value: Value, _ key: some PersistenceKey) { let key = InMemoryKey<Value>(key: key) if let storage = sharedStates[key] as? Storage { self.storage = storage } else { self.storage = Storage(value: value) sharedStates[key] = self.storage } }

33:30

With that change everything should work with ubiquitous shared state exactly as it did before. But now we are starting to make basic use of the persistence strategy, which will hopefully set us up to incorporate more complex forms of persistence besides “in memory.”

33:45

In fact, right now we aren’t using much from the persistence strategy other than the fact that it is hashable. We haven’t even invoked the load or save endpoints yet, and that’s because the in-memory strategy doesn’t do anything for those endpoints.

33:59

Where we will start using those endpoints is when trying make use of the AppStorageKey persistence strategy.

34:09

We can try to use the new initializer with a syntax like this: @Shared(AppStorageKey<Bool>(key: "isOn")) var isOn = false

34:18

Well, that is if we make AppStorageKey public: public struct AppStorageKey<Value>: PersistenceKey { … }

34:24

And give it a public initializer: public init(key: String) { self.key = key }

34:37

But this syntax isn’t as nice and succinct, or as discoverable as what we theorized a moment ago: @Shared(.appStorage("isOn")) var isOn = false

34:47

In order for that to work we need a static method on the PersistenceKey protocol that is tuned specifically for creating AppStorageKey s: extension PersistenceKey { public static func appStorage<Value>( _ key: String ) -> Self where Self == AppStorageKey<Value> { AppStorageKey(key: key) } }

35:26

And with that done this syntax is looking quite nice: @Shared(.appStorage("isOn")) var isOn = false

35:29

But it’s not compiling: Generic parameter ‘Value’ could not be inferred

35:35

The initializer that takes some PersistenceKey has no type information whatsoever about the value being persisted: public init( wrappedValue value: Value, _ key: some PersistenceKey ) { … }

35:47

But there is a way for us to surface this Value type very publicly in this signature so that Swift knows how to figure it out from the context. We can add a “primary associated type” to the PersistenceKey protocol: public protocol PersistenceKey<Value>: Hashable { … }

36:05

With that one little change we now get to say that the Shared initializer not only takes some PersistenceKey , but it takes some Persistent of Value : public init( wrappedValue value: Value, _ key: some PersistenceKey<Value> ) { … }

36:15

And now our theoretical code is compiling and looking great. In fact, I like this so much I want it to work this way with the in-memory persistence strategy too. We can add a new static method on the PersistenceKey protocol: extension PersistenceKey { public static func inMemory<Value>( _ key: String ) -> Self where Self == InMemoryKey<Value> { InMemoryKey(key: key) } }

36:42

And now anywhere we were passing a string directly to @Shared we will instead pass the .inMemory persistence strategy with the string key specified: @Shared(.inMemory("stats")) var stats = Stats()

36:59

We can even delete the previous Shared initializer that takes a string key: // public init(wrappedValue value: Value, _ key: String) { // let key = InMemoryKey<Value>(key: key) // if let storage = sharedStates[key] as? Storage { // self.storage = storage // } else { // self.storage = Storage(value: value) // sharedStates[key] = self.storage // } // }

37:05

But we are still not making use of the load and save endpoints defined on the persistence strategy passed in.

37:13

Since the Storage class holds onto the true value of the Shared state, let’s have it also hold onto the persistence strategy, and then it can be responsible for doing any loading and saving: @Perceptible class Storage { let persistenceKey: (any PersistenceKey<Value>)? … }

37:39

And then we will update the initializer to pass in the persistence strategy and assign it: init( value: Value, persistenceKey: (any PersistenceKey<Value>)? = nil ) { … self.persistenceKey = persistenceKey }

37:52

And now when constructing Shared we can pass the persistence on to the storage: public init( wrappedValue value: Value, _ key: some PersistenceKey<Value> ) { if let storage = sharedStates[key] as? Storage { self.storage = storage } else { self.storage = Storage( value: value, persistenceKey: key ) sharedStates[key] = self.storage } }

38:01

And then what we’d like to do is try loading the current value from the persistence, and if that succeeds then we can disregard the value passed in: self.currentValue = persistenceKey?.load() ?? value

38:25

This now compiles, and we have the loading half of persistence implemented, all that is left is saving.

38:28

That can be handled in the didSet of the currentValue stored property: var currentValue: Value { didSet { self.persistenceKey?.save(self.currentValue) } } So each time the value is changed in the shared state we will call out to the persistence to tell it to save.

38:41

And that right there is the basics of persistence, at least as far as user defaults is concerned. Let’s give it a spin. We will add an isOn property to the counter tab that is backed by user defaults: @Reducer struct CounterTab { @ObservableState struct State: Equatable { @Shared(.appStorage("isOn")) var isOn = false … } … }

39:01

And we will add an action to toggle that state: enum Action { … case toggledIsOn(Bool) }

39:09

And we will implement that action in the reducer: case let .toggledIsOn(isOn): state.isOn = isOn return .none

39:19

And we can add a toggle that is driven off of this new isOn state, and such that when it is the state is flipped it sends the setIsOn action: Toggle(isOn: $store.isOn.sending(\.toggledIsOn)) { Text("Store.isOn: \(store.isOn.description)") }

39:41

And now let’s test things out.

39:49

Things seem to work really well if I flip the Store.isOn toggle on and off. It immediately makes the @AppStorage toggle also turn on and off. Further, if I turn the toggle on, and then relaunch the app, the app will start with the toggle on. So the data is definitely being persisted to user defaults and loaded from user defaults.

40:10

However, if I try to flip the other toggle, then we will see that the unfortunately our new toggle does not flip. Next time: External changes

40:21

Currently the shared state is not listening for changes to the user defaults that happens externally so that it can update itself accordingly. This is the same problem we saw with holding onto @AppStorage in an @Observable object. It could clearly load from and save to user defaults, but it didn’t observe changes made to user defaults from other parts of the application. Brandon

40:41

But unlike that situation, we can actually fix it for our @Shared property wrapper. We can beef up the PersistenceKey protocol so that conformers to it are capable of describing updates to the value that happen in the external system, such as user defaults or the file system, and then the Storage type can use that information to update its currentValue accordingly.

41:07

Let’s see what it takes…next time! Downloads Sample code 0273-shared-state-pt6 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 .