Video #274: Shared State: User Defaults, Part 2
Episode: Video #274 Date: Apr 8, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep274-shared-state-user-defaults-part-2

Description
We can now persist simple bits of state to user defaults using the @Shared property wrapper, but there is more work to be done. We need to observe changes to user defaults in order to play those changes back to @Shared, and we need to put in a bit of extra work to make everything testable.
Video
Cloudflare Stream video ID: 4d75f44178595bd6456c8186cfcb4219 Local file: video_274_shared-state-user-defaults-part-2.mp4 *(download with --video 274)*
Transcript
— 0:05
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
— 0:25
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.
— 0:52
Let’s see what it takes. External updates
— 0:55
We will express this idea as a new endpoint in the PersistenceKey protocol as an AsyncStream of values that can emit whenever the external storage changes: public protocol PersistenceKey<Value>: Hashable { associatedtype Value func load() -> Value? func save(_ value: Value) var updates: AsyncStream<Value> { get } }
— 1:15
Now often an AsyncStream is not the correct thing to use because it does not support multiple subscribers. However, in our case the single, global Storage reference will be the only subscribers, and so it is OK for our purposes.
— 1:31
Now we have some compiler errors where we need to provide this new requirement for all conformances to the PersistenceKey protocol. The InMemoryKey example is quite easy because it will never receive updates from an external system, and therefore we can just use an AsyncStream that emits nothing and immediately completes: public struct InMemoryKey<Value>: PersistenceKey { … public var updates: AsyncStream<Value> { .finished } }
— 2:11
And then for AppStorageKey we do want to perform some real work to implement this requirement: public struct AppStorageKey<Value>: PersistenceKey { … public var updates: AsyncStream<Value> { AsyncStream<Value> { continuation in } } }
— 2:30
We want to listen for any changes in user defaults to the key held inside AppStorageKey , and if a change is detected we will emit it through this stream’s continuation. That will tell the Storage type to update its currentValue , which will then cause any views accessing the state to automatically update thanks to the magic of the observation tools in Swift.
— 2:53
The way to observe a key in user defaults is through the key-value observing machinery on NSObject , which can be thought of as an Objective-C predecessor of some of the newer technologies introduced with Swift, such as Combine and Observation.
— 3:08
Every NSObject , including UserDefaults , has a method called addObserver : UserDefaults.standard.addObserver( <#observer: NSObject#>, forKeyPath: <#String#>, options: <#NSKeyValueObservingOptions#>, context: <#UnsafeMutableRawPointer?#> )
— 3:17
It takes an object that acts as the observer, and it is the object that will receive notifications when something changes. Let’s skip past this argument for now.
— 3:24
The second argument is the key in the user defaults we want observe, and so that’s the key we hold onto in AppStorageKey : UserDefaults.standard.addObserver( <#observer: NSObject#>, forKeyPath: self.key, options: <#NSKeyValueObservingOptions#>, context: <#UnsafeMutableRawPointer?#> )
— 3:36
The third argument allows us to specify if we want the new value when it changes, the old, or both. We just need the new: UserDefaults.standard.addObserver( <#observer: NSObject#>, forKeyPath: self.key, options: .new, context: <#UnsafeMutableRawPointer?#> )
— 3:51
And the context will not be used by us and so we will pass nil : UserDefaults.standard.addObserver( <#observer: NSObject#>, forKeyPath: self.key, options: .new, context: nil )
— 3:55
So now we just need to provide an observer. We can do this by creating a whole new NSObject subclass: class Observer: NSObject { }
— 4:16
And every NSObject has an observeValue method that can be overridden: override func observeValue( forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer? ) { }
— 4:22
And it is this method that will be called when a change is observed in some other object.
— 4:32
The change argument holds onto the new value after the change, we just have to do some casting to get access to it: guard let value = change?[.newKey] as? Value else { return }
— 4:53
And we now have the value that we want to emit from the stream, which we could do if we had access to the continuation.
— 4:59
So, let’s have the Observer hold onto the continuation: class Observer: NSObject { let continuation: AsyncStream<Value>.Continuation init(continuation: AsyncStream<Value>.Continuation) { self.continuation = continuation super.init() } … }
— 5:13
So that we can yield to it when a value has been observed: override func observeValue( forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer? ) { guard let value = change?[.newKey] as? Value else { return } continuation.yield(value) }
— 5:30
Now in our stream we can construct an observer and hand it off to addObserver : AsyncStream<Value> { continuation in let observer = Observer(continuation: continuation) UserDefaults.standard.addObserver( observer, forKeyPath: self.key, options: [.new], context: nil ) }
— 5:46
And further, if the continuation is ever terminated we can automatically remove the observer: continuation.onTermination = { _ in UserDefaults.standard.removeObserver( observer, forKeyPath: self.key ) }
— 6:10
That all it takes to have a legitimate stream of values that are broadcast from changes to the global user defaults.
— 6:18
Now we just need to find a place to subscribe to this stream and update the shared state when changes are detected. This can be done in the Storage type since all other interactions with the persistence strategy have happened there.
— 6:43
Right in the initializer we can spin up a task, subscribe to the updates, and mutate the currentValue : init( value: Value, persistenceKey: (any PersistenceKey<Value>)? = nil ) { self.currentValue = persistenceKey?.load() ?? value self.persistenceKey = persistenceKey if let updates = persistenceKey?.updates { Task { @MainActor in for await value in updates { self.currentValue = value } } } }
— 7:24
That is all it takes, and now our @Shared type will be well-behaved just like SwiftUI’s @AppStorage . Flipping one immediately flips the other, and further making a change directly to the user defaults causes both toggles to immediately update. And of course if we re-launch the app we will see that the toggles have started in their last state.
— 9:19
And if we were to use this state in multiple parts of the application it would all stay in sync. For example, we can add some isOn state to the profile feature: @Reducer struct ProfileTab { @ObservableState struct State: Equatable { @Shared(.appStorage("isOn")) var isOn = false … } … } [00:009:40] And we won’t even add an action to mutate the state. Instead we will just show the state in the view: Section { Text("Is on: \(store.isOn.description)") }
— 9:54
We can run the simulator to see this works. However we toggle the value in the first tab, the second tab will always show the freshest value. Testing app storage
— 10:08
This is pretty incredible. We now have a property wrapper that mimics the functionality of SwiftUI’s @AppStorage property wrapper. It reads from and writes to user defaults, and it listens for any changes made to user defaults so that if someone from the outside writes over our key our state will update and views will refresh.
— 10:29
But, things are a little better than vanilla SwiftUI’s @AppStorage because our property wrapper works outside of SwiftUI views. In particular, it works inside the state of our reducers, which means we have immediate access to it right along side where the rest of the logic of our feature is. Stephen
— 10:47
This is in stark contrast with vanilla SwiftUI, where if you want to extract a feature’s logic out to an observable model then you have no choice but to split the state of your application into two completely different worlds. The view layer holds onto any user defaults backed data, and your observable model holds onto everything else.
— 11:06
This creates a confusing situation, and scatters the code for a single feature into multiple places. You will inevitably have to cook up various ways to copy data around, or synchronize state between the view layer and observable model, and those are exactly the kinds of problems we wanted to solve when designing the @Shared property wrapper.
— 11:25
And even better, this state is still 100% testable using TestStore s. That’s right. Even though it is reaching out to the scary, uncontrollable world of user defaults, we can make this code completely testable, and it can be asserted against just like any other state in a Composable Architecture feature.
— 11:43
Let’s take a look.
— 11:46
Let’s write a quick test that exercises the isOn logic. Let’s start simple by just writing a test only for the CounterTab feature rather than the fully composed SharedState feature. We can construct a TestStore focused in on the counter domain, and then we will send the setIsOn action and assert that the state flipped to true: func testIsOn() async { let store = TestStore(initialState: CounterTab.State()) { CounterTab() } await store.send(.toggledIsOn(true)) { $0.isOn = true } }
— 12:30
This test passes!
— 12:34
So, that seems promising, but it would be far too wishful to think that everything works perfectly. For example, we can stop asserting on the state entirely: await store.send(.toggledIsOn(true)) { // $0.isOn = true _ = $0 }
— 12:46
…and the test will still pass.
— 12:48
That doesn’t seem right. Let’s update the assert to send false instead: await store.send(.toggledIsOn(false)) { // $0.isOn = true _ = $0 }
— 12:56
Now this fails: A state change does not match expectation: … CounterTab.State( − _isOn: true, + _isOn: false, _alert: nil, _stats: Shared.Storage(…) ) (Expected: −, Actual: +)
— 13:07
So, certain kinds of test failures seem to be picked up. But then if I run the test again with no changes, somehow it passes.
— 13:15
What we are seeing here is that our direct access to user defaults has caused global data to leak from one test to another. When sending true in the action we got a test failure because we didn’t assert on that change, but secretly it also wrote true to user defaults. This means next time we run the test the default value of isOn is already true , and so when we send true again nothing changes, and so the test passes.
— 13:29
All we have to do is take back control over this dependency on user defaults so that we can provide a controlled environment for our tests to operate in. And interestingly SwiftUI even provides the ability to override the user defaults used with the @AppStorage property wrapper. In fact, it provides two ways to do so.
— 13:43
The first way is to provide an explicit user defaults to the @AppStorage property wrapper: struct CounterTabView: View { @AppStorage( "isOn", store: UserDefaults(suiteName: "pointfree")! ) var isOn = false … }
— 14:00
That right there makes this app storage run of a different user defaults than our @Shared state, and so now the toggles operate independently. The data will still be persisted. We can verify that be relaunching the simulator. But changes to one piece of storage no longer affects the other.
— 14:40
That works, but also would be a bit of a pain to have to provide that argument every time you use @AppStorage . And it would be easy to forget, which could subtly break your application.
— 14:53
There’s another tool SwiftUI provides, and it’s a view modifier that allows you to implicitly set the default app storage used for every @AppStorage property in a subset of the view hierarchy. So, if we apply it to where the CounterView is constructed: CounterTabView( store: self.store.scope( state: \.counter, action: \.counter ) ) .defaultAppStorage(UserDefaults(suiteName: "pointfree")!)
— 15:22
Now any @AppStorage properties used in CounterTabView or any of its subviews will use this alternate user defaults.
— 15:31
And this view modifier does its magic through the environment values system, which we can verify by printing the type of the view: let _ = print( type( of: CounterTabView( store: self.store.scope( state: \.counter, action: \.counter ) ) .defaultAppStorage(UserDefaults(suiteName: "pointfree")!) ) )
— 15:45
Running this will show the following: ModifiedContent< CounterTabView, _EnvironmentKeyWritingModifier<NSUserDefaults> >
— 15:53
And so we can definitely see that some kind of environment writing happened.
— 15:58
This seems like a great way to override the kind of user defaults used in a part of the application, and luckily for us the Composable Architecture comes with a robust dependency management system that makes it possible for us to mimic this with very little work.
— 16:11
Right now over in the AppStorageKey type we have a bunch of places where we are reaching out the global, uncontrollable UserDefaults.standard : public func load() -> Value? { UserDefaults.standard.value(forKey: self.key) as? Value }
— 16:20
This writes to a fully global store of data, and it will be persisted across test runs.
— 16:28
What if instead we had a UserDefaults object registered with the dependency system so that we could override it in tests, previews and other situations? We can do this by creating a key type that conforms to DependencyKey : private enum DefaultAppStorageKey: DependencyKey { }
— 16:50
At a minimum we need to provide a liveValue , which represents the value used in the simulator and on real devices. For this value it is appropriate to use the standard user defaults: private enum DefaultAppStorageKey: DependencyKey { static let liveValue = UserDefaults.standard }
— 17:02
And let’s just go with the minimal implementation for now. We will worry about the test value and preview value later.
— 17:09
But before moving on we are getting a warning right now: Conformance of ‘UserDefaults’ to ‘Sendable’ is unavailable
— 17:12
It seems that the UserDefaults type conforms to the Sendable protocol, but that its conformance has been marked as unavailable. However, the documentation for UserDefaults explicitly says that the class is thread safe: Thread Safety The UserDefaults class is thread-safe.
— 17:37
From what we’ve heard through the grapevine it seems that UserDefaults definitely is thread safe, and should be fully Sendable , but that it hasn’t be properly audited yet and so the unavailable annotation remains.
— 17:48
And unfortunately even doing a @preconcurrency import doesn’t help: @preconcurrency import Foundation
— 18:01
For situations like this, where we know the thing is sendable but for whatever reason it is not yet marked as sendable, we think it is ok to usher it through as an UncheckedSendable , which we can do thanks to a simple type from our ConcurrencyExtras library: private enum DefaultAppStorageKey: DependencyKey { static let liveValue = UncheckedSendable(UserDefaults.standard) }
— 18:25
Now this compiles with no warnings.
— 18:27
Next we can add a computed property to DependencyValues so that it can be easily overridden from the outside: extension DependencyValues { public var defaultAppStorage: UserDefaults { get { self[DefaultAppStorageKey.self].value } set { self[DefaultAppStorageKey.self].value = newValue } } }
— 19:05
Now we will add this dependency to our AppStorageKey type: public struct AppStorageKey<Value>: PersistenceKey { @Dependency(\.defaultAppStorage) var defaultAppStorage … }
— 19:26
This breaks the automatic synthesis of the Hashable conformance, but that’s OK. The Hashable and Equatable conformance only needs to take into account the key , and that’s easy enough to implement: public func hash(into hasher: inout Hasher) { hasher.combine(self.key) } public static func == (lhs: Self, rhs: Self) -> Bool { lhs.key == rhs.key }
— 19:57
Now everything is compiling, and we will use this user defaults instead of the standard one throughout the AppStorageKey type. For example, in the load and save endpoints: public func load() -> Value? { defaultAppStorage.value(forKey: self.key) as? Value } public func save(_ value: Value) { defaultAppStorage.setValue(value, forKey: key) }
— 20:11
And the updates stream: public var updates: AsyncStream<Value> { AsyncStream<Value> { continuation in let observer = Observer(continuation: continuation) defaultAppStorage.addObserver( observer, forKeyPath: self.key, options: [.new], context: nil ) continuation.onTermination = { _ in defaultAppStorage.removeObserver( observer, forKeyPath: self.key ) } } }
— 20:15
With that done we now have the ability to provide alternative user defaults in certain situations, such as tests.
— 20:22
If we go back to the test we wrote a moment ago and run it with no changes we get the following test failure: @Dependency(\.defaultAppStorage) has no test implementation, but was accessed from a test context:
— 20:40
Typically this is a good test failure to have since it loudly lets you know that you are accessing a dependency without having explicitly overridden it.
— 20:50
So, let’s override it by providing a new user defaults that carry over any of the changes from previous test runs. I guess one thing we could do is create a user defaults with a unique suite name: let store = TestStore(initialState: CounterTab.State()) { CounterTab() } withDependencies: { $0.defaultAppStorage = UserDefaults( suiteName: UUID().uuidString )! }
— 21:08
That will certainly do the job. We can go back to our first assertion: await store.send(.toggledIsOn(true)) { $0.isOn = true }
— 21:17
This passes. But if we mess up the assertion: await store.send(.toggledIsOn(true)) { $0.isOn = false }
— 21:27
…it fails, and fails with a good message.
— 21:51
So that seems good, but also it’s a bit strange we are going to create all new user defaults suites every time we run tests. We run the risk of creating hundreds or thousands of these suites, all holding data that may never get cleaned up. And for no real reason.
— 22:11
Luckily there is a better way. We can create a user defaults that simply doesn’t ever persist its changes: let store = TestStore(initialState: CounterTab.State()) { CounterTab() } withDependencies: { let suiteName = "pointfree.co" $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! $0.defaultAppStorage.removePersistentDomain( forName: suiteName ) }
— 22:30
This gives each test run a clean slate of user defaults so that any changes made to it don’t accidentally leak out into other test runs, and it won’t flood the system with forgotten suites.
— 22:40
And so this is looking much better, but honestly I think it’s a bit much to force users of the @Shared property wrapper to override the defaultAppStorage just to test their state.
— 22:50
It’s true that we prefer for people to write exhaustive tests, where they assert on how all state changes and how all interactions with the outside world happen. But this is going too far. I think it’s reasonable to write this test as if the isOn state is just regular state without thinking about the fact that it is secretly also being persisted to user defaults.
— 23:08
And certainly we would like some test coverage on the AppStorageKey persistence strategy. After all, it has started to get quite complex. But those tests can be written a single time as a library-level concern. The user of the library shouldn’t have to essentially repeatedly test that behavior just to write a few simple tests for their own feature.
— 23:26
So, for that reason we think the testValue for the defaultAppStorage dependency should just be this non-persisting defaults: private enum DefaultAppStorageKey: DependencyKey { static let liveValue = UncheckedSendable(UserDefaults()) static var testValue: UncheckedSendable<UserDefaults> { let suiteName = "pointfree.co" let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) return UncheckedSendable(defaults) } }
— 24:10
And then we can get rid of all the dependencies nonsense in our test: func testIsOn() async { let store = TestStore(initialState: CounterTab.State()) { CounterTab() } … }
— 24:13
And this test will now pass just fine, and there is no risk of non-determinism or tests leaking out into other tests.
— 24:20
And if we get the assert wrong: await store.send(.toggledIsOn(true)) { $0.isOn = false }
— 24:24
We get a nicely formatted test failure message: A state change does not match expectation: … CounterTab.State( − _isOn: false, + _isOn: true, _alert: nil, _stats: Shared.Storage(…) ) (Expected −, Actual: +)
— 24:35
We are getting exhaustive, 100% deterministic testing on this even though we have a reference in our state. And even though that reference is secretly a global shared by the entire application. And even though the data in the reference is secretly being persisted to user defaults.
— 24:50
That is absolutely incredible. Next time: file storage
— 24:52
We have accomplished quite a bit in this series so far. We now have 2 primary methods of sharing state in a Composable Architecture application:
— 24:59
We can use a simple, unadorned @Shared property wrapper to represent a piece of state that is passed in from the parent feature, and shares its state with the parent.
— 25:08
Or we can provide a persistence strategy to the @Shared property wrapper that describes how we want the state to be persisted to an outside system. This makes it possible to globally share the state with any part of the application without needing to explicitly pass it around, and even persist the data to some external storage. Currently we have two persistence strategies: We have an in-memory persistence strategy that just doesn’t persist data at all. This means you will lose the data when the app re-launches, but that can be fine for many use cases. And we have a user defaults persistence strategy that saves simple data types to user defaults. We even went the extra mile to observe changes to user defaults so that if someone makes a change outside of the @Shared property wrapper we can be sure to update our state. Brandon
— 25:48
This is all great, but user defaults persistence is a bit limited. It only works for the most basic of data types, such as booleans, integers and strings, and therefore is appropriate for little scraps of data and user preferences.
— 28:03
There are many times that we need to persist far more significant chunks of data, and the easiest place to do that is directly on the device’s file system. And the easiest way to serialize the data to disk is via Swift’s Codable protocol and JSON serialization.
— 26:18
Let’s see what it takes to introduce a 3rd conformance to the PersistenceKey protocol and make file storage a reality in Composable Architecture features…next time! Downloads Sample code 0274-shared-state-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 .