This week we are incredibly excited to announce the release of a brand new open source project, and we call it “Sharing”.
It provides an amazingly versatile tool for sharing state amongst many features in an application, and it can share state with external systems, such as user defaults, file storage, SQLite, and really anything! It works with SwiftUI when installed directly in a view, it works with UIKit when installed directly in a UIViewController
or UIView
, it works when installed in an @Observable
model, it works anywhere, really! It’s also powered by our robust Dependencies library, which means the tool works wonderfully with testing and Xcode previews.
It’s also worth mentioning that if any of our viewers have ever used our Composable Architecture library, then some of this content will look quite familiar to you. That’s because these state sharing tools were first built for that library, but we later realized that they would be useful in vanilla SwiftUI, UIKit, and the library even compiles for Linux, Windows and Wasm! But, even if you think you are familiar with our state sharing tools, we promise there are a few new tricks that you have never seen before.
In the next few episodes we will give a tour of the library to show how easy it is to use while exploring some of its advanced features and showing the possibilities for customization.
We have a lot to cover, so let’s jump in!
I’ve got a fresh Xcode project started right here, and first thing I am going to do is turn on Swift 6 language mode so that we are working with the most strict concurrency settings…
Before diving into our library let’s play around with some vanilla SwiftUI so that we can understand the tools that inspired our library. We will first introduce a bit of local @State
to our view, and we will of course turn to our beloved counter:
struct ContentView: View {
@State var count = 0
var body: some View {
Form {
Text("\(count)")
.font(.largeTitle)
Button("Decrement") {
count -= 1
}
Button("Increment") {
count += 1
}
}
}
}
The @State
property wrapper creates some local state for the view that stays alive for as long as the view is alive. That means we can navigate away from this view, such as drilling-down to a screen or presenting a sheet, and this counter state will stick around. The entire ContentView
struct can be re-created dozens or hundreds of times, and yet somehow the @State var count
will hold onto its current value. It will not reset back to 0 until this feature is fully removed from the view hierarchy.
However, this value will not persist across app launches or preview runs. In fact, if we simply make a change to the view:
Button("Decrement!") {
count -= 1
}
…we will see the count goes back to zero. And if we run the app in the simulator, increment a few times, and relaunch the app, the count has also gone back to zero.
Now sometimes it is OK to hold onto ephemeral state like this that should solely belong to the view and not be persisted. But other times you do want the value to stick around. And for this situation there is another property wrapper that can be used, called @AppStorage
:
@AppStorage("count") var count = 0
With that one small change the app works almost exactly as before, but now with the added behavior that the count persists across app and preview runs. And the persistence mechanism used is UserDefaults
, which is a simple key value storage system provided on all of Apple’s platforms.
To test this, I can increment the count in the preview, and then change the button label back:
Button("Decrement") {
count -= 1
}
…and we will see that the preview refreshes with the count retained. And we can do the same in the simulator. And so that’s pretty amazing, and it’s small things like this that give us a big “wow” factor when working with SwiftUI. Just one tiny change to how we hold onto the count
property in our view turns it into something that is local to just the view to something that is persisted across launches.
Further, because the source of truth of this state actually lies with UserDefaults
, the state is globally accessible in the app. For example, let’s rename the ContentView
to just CounterView
, and make it responsible for just showing the count and incrementing/decrementing it:
struct CounterView: View {
@AppStorage("count") var count = 0
var body: some View {
Text(count.description)
.font(.largeTitle)
Button("Decrement") {
count -= 1
}
Button("Increment") {
count += 1
}
}
}
And then let’s introduce a ManyCountersView
that embeds two versions of this CounterView
into a form:
struct ManyCountersView: View {
var body: some View {
Form {
Section {
CounterView()
}
Section {
CounterView()
}
}
}
}
We will now see two counters on the screen, but incrementing or decrementing either of them will update both. So @AppStorage
allows us to share small bits of state with the entire application with very little work.
Further, @AppStorage
goes the extra mile by also observing changes to UserDefaults
so that it can update its state if someone were to write directly to the storage. For example, suppose we add a button to reset the “count” back to 0, but we do so through the UserDefaults
instead of accessing through @AppStorage
:
Button(#"UserDefaults.set(0, "count")"#) {
UserDefaults.standard.set(0, forKey: "count")
}
Tapping this button immediately resets both counters to 0. And this is really great because we may want to allow other, non-SwiftUI parts of the app to read and write to this “count” key in user defaults, and we can rest assured that our views will always have the freshest value. It would be a bummer of a legacy or decoupled part of the app needed to make changes to UserDefaults
directly and for those changes to go unnoticed by SwiftUI.
So this is all great, but let’s quickly talk about a few things that are not so great. We will start with the fact that @AppStorage
only works when installed directly in a SwiftUI view. In particular, it does not work when installed in an observable model or used anywhere outside of SwiftUI.
To explore this, let’s define a simple @Observable
model that holds onto some app storage:
@Observable
class CounterModel {
@AppStorage("count") var count = 0
}
This already doesn’t work, and the error message is not only hidden from us, but also cryptic:
Invalid redeclaration of synthesized property ’_count’
This is happening because macros, like @Observable
, do not work with property wrappers. We have no choice but to tell the macro to skip over this field when instrumenting the class:
@ObservationIgnored
@AppStorage("count") var count = 0
That may seem bad, but @AppStorage
is already performing some kind of observation magic under the hood since it works when installed in a view, and so it shouldn’t need the magic from the @Observable
macro.
With that done we can start to make use of this model rather than using @AppStorage
directly in the view:
struct CounterView: View {
@State var model = CounterModel()
var body: some View {
Text(model.count.description)
Button("Decrement") {
model.count -= 1
}
Button("Increment") {
model.count += 1
}
}
}
With that done everything is now compiling, but sadly nothing works. Tapping increment, decrement or reset does nothing at all.
And so we are seeing that if you want to get access to your @AppStorage
state somewhere else besides directly in the view, then you will have no choice but to resort to interacting with UserDefaults
directly. And if you want to be notified when the state changes, you will need to handle that yourself.
And there certainly is a coalition of folks out there that feel like this is not a big deal because everything should go in the view anyway. That is their prerogative, and one can get a lot done that way, but at the end of the day the Observation framework and @Observable
macro exist for a reason. Sometimes it really is necessary to extract logic and behavior out of a view and into a model, and that’s not to even mention that often apps will have a mixture of some UIKit too. And good luck dealing with that!
And so it’s a real bummer that if you choose to introduce an observable model to your code base, or add a UIKit feature, you will need to stratify your application’s state into two separate areas: state that can only exist in the view, and state that is held in an observable model.
So, that does seem bad, but there are a few other downsides to using @AppStorage
that one should be aware of. Let’s take a look.
Since holding onto @AppStorage
in a model is a non-starter, let’s undo all of this work and go back to holding @AppStorage
directly in the view…
The next downside to @AppStorage
that we want to mention may seem small at first, but it does have repercussions. When we set the count directly through user defaults:
UserDefaults.standard.set(0, forKey: "count")
…that does not immediately and synchronously update the value stored in @AppStorage
. We can see this by putting a print statement right before and after setting the “count” back to 0 directly in the user defaults:
print(count)
UserDefaults.standard.set(0, forKey: "count")
print(count)
Running this, incrementing a few times, and then reseting prints the following to the console:
3
3
In order to see the most current value in @AppStorage
we have to wait for a tick of the runloop:
print(count)
UserDefaults.standard.set(0, forKey: "count")
DispatchQueue.main.async {
print(count)
}
Now this prints what we would expect:
3
0
This may not seem like a big deal, after all can’t we always just go through @AppStorage
? Why even go directly through UserDefaults
?
Well, as we saw a moment ago, only views get to interact with @AppStorage
, and so if you have other parts of your app that need to interact with this state they will be forced to use UserDefaults
. And because @AppStorage
incurs a thread hop before updating its value, that means we can’t ever animate those changes from the outside.
To see this, let’s wrap the reset work in a withAnimation
:
withAnimation {
UserDefaults.standard.set(0, forKey: "count")
}
When we run this in the preview we will see that the count goes to zero immediately with no animation.
And even if we had mutated the count
directly:
withAnimation {
count = 0
}
…it still doesn’t animate.
And what this is really showing is that the thread hop introduced by @AppStorage
destroys our ability to deal with this state in a structured manner. We can’t wrap the work performed by @AppStorage
in a lexical scope, such as an animation:
withAnimation {
UserDefaults.standard.set(0, forKey: "count")
}
…or a transaction:
withTransaction(…) {
UserDefaults.standard.set(0, forKey: "count")
}
…or a lock:
lock.withLock {
UserDefaults.standard.set(0, forKey: "count")
}
The value held in the @AppStorage
will be updated after these lexical scopes have ended, and that means there are just certain things we will never be able to do when interacting with the external system, which is user defaults, directly.
Let’s move onto another downside, and that is the fact that app storage, by default, bleeds across previews.
We currently have 2 previews that use the same app storage “count
” key, and if we increment one to a certain count and load the other preview, it already has that count. And the same is true for the opposite direction.
Most of the time you want your previews to start up in a very specific state, but because @AppStorage
is not only persisted, but it is persisted across all of the previews in your app, that makes it a lot more difficult to do so.
Let’s say we want to have one preview simulate what it looks like when the count has been incremented to a very large number:
#Preview("CounterView") {
let _ = UserDefaults.standard.set(10_000, for: "count")
CounterView()
}
We will see that this preview immediately reflects this large value, but unfortunately the act of simply running this preview has bled into the other preview, which when loaded also shows this large value.
So if we want the other preview to start in a specific state, it too needs to reset the default count to some other value, and while this works, it also means that every time we want to build a preview that uses app storage we must also remember to do this up front work, which seems like a serious pain.
And the last downside we want to mention is not really a problem with @AppStorage
directly, but rather just the ecosystem of property wrappers provided by SwiftUI. The @AppStorage
property works specifically with user defaults, which is only meant to store very simple, small amounts of data. Things like numbers, booleans, strings. It is not meant to persist large pieces of data, such as complex data types that can be serialized to JSON. One can certainly shoehorn that into user defaults, but one is likely to run into problems.
And there is of course the Swift Data framework, which comes with the @Query
macro for loading models from a database. We can look at an example usage of this macro in the docs:
struct ContentView: View {
@Query(sort: \.startDate, order: .reverse) var allTrips: [Trip]
var body: some View {
List {
ForEach(allTrips) {
TripView(for: $0)
}
}
}
}
This allows you to store very complex models in a database and query for them. And it’s a very powerful framework, but it is also way, way more powerful than @AppStorage
. There is no persistence strategy that lies somewhere between saving little primitive types in user defaults and committing to a full-blown database schema.
Maybe you just want to start with a simple Codable
data type, and use the file system to save and load its values:
@FileStorage(.documentsDirectory.appending(component: "trips.json"))
var trips: [Trip] = []
It would be pretty cool if this behaved like @AppStorage
but interacting with the file system instead. When launching the app the trips would be loaded from disk and stored in this state. And any changes you make to the trips would be persisted to the disk automatically. And even better, what if the property wrapper could detect changes to the file system so that it could update the state if someone wrote directly to the file without going through this property wrapper.
And beyond file storage, wouldn’t it be nice to have a unified set of tools for creating our own strategies for holding onto state whose source-of-truth comes from some external system?
For example, suppose we wanted to hold our data in a SQLite database, but not using Swift Data. Perhaps we want to use GRDB instead, and so we would like to have a property wrapper or macro interface like we have with Swift Data:
@GRDBQuery(sort: \.startDate, order: .reverse) var trips: [Trip]
Or perhaps we want the ability to introduce state to our views that is actually controlled by a remote server, such as feature flags or A/B experiments. It would be nice if we could have a property wrapper or macro to describe a remote configuration value:
@RemoteConfig("largeCount") var isLargeCountEnabled = false
…so that we could then use it right in the view:
Text(model.count.description)
.font(isLargeCountEnabled ? .largeTitle : nil)
And even better if this remote config could automatically subscribe to any changes on the server, so that if we flip a feature on from our server, all of the apps out in the wild will instantly update.
This would all be pretty amazing, but there is no unified approach to any of these problems. We would just have to create a bunch of disparate property wrappers or macros that accomplish these goals, and further it would all still probably be relegated to only the SwiftUI view layer. We would not be able to get access to this state from anywhere else in our app, such as @Observable
models or if we still have some features built in UIKit.
We have now seen everything that @AppStorage
has to offer, both the good and the bad. The good is obvious right from the beginning:
It immediately works in any view without any extra work. You can essentially swap out an existing
@State
variable for an@AppStorage
variable, and bam. Your app will immediately start persisting that state.
Further,
@AppStorage
goes above and beyond by also listening for changes in user defaults so that if someone mutates the value directly inUserDefaults
, the@AppStorage
state will automatically update.
However, there are some downsides that are not so immediately clear:
@AppStorage
only works when installed directly in the view. If you extract it out to an observable model, or try to use it in a UIKit feature, it will not work at all. And so then you will be forced to essentially rewrite that code and deal with user defaults and KVO observation yourself, which is tricky.And regardless of your preference of where to put an app’s logic and behavior, the fact of the matter is that
@Observable
models have their place in an app, and the moment you introduce one you have unwittingly stratified your app’s state into two different styles.
Less pernicious, but still serious, is that updating user defaults incurs a thread hop before updating
@AppStorage
, which means you can’t animate those changes or wrap those changes in any kind of transactional or structured process.
And then finally, less of a downside but still a concern, is that there is no unified approach to holding onto state in your features that hold their “source-of-truth” with some external system, such as the file system, or a remote server of configuration values, and more.
And these gaps are exactly what our Sharing library was built to address. We would like:
…a single tool for sharing state with many parts of your app whose “source-of-truth” lies in some external system.
The tool should be extensible to work with a wide variety of external systems, such as user defaults, file storage, SQLite, remote servers, and a lot more.
Ideally the tool shouldn’t have any restrictions on where and how it is used in your features. Some people like to hold onto the state directly in their views, and others like to keep the state in a separate domain model. We should be free to choose where we want the data, and most likely in real world, complex apps we will probably have a mixture of both.
And if all of that wasn’t enough, we further want to accomplish all of this in a super short, succinct syntax with as few concepts as possible.
Oh, and one last thing, we’d like the whole thing to be testable so that we can create previews and write unit tests for our complex app logic without fear of interacting with these outside systems or having mutable data spill over from preview to preview or test to test.
And our newly released Sharing library accomplishes all of this, and if you can believe, a lot more. So, let’s start exploring what the library has to offer. We will start with its simplest tool, which is a version of @AppStorage
that works everywhere, not just views.
Let’s dig in.
Let’s start by importing our new Sharing library:
import Sharing
Then I will use Xcode to search for our library and add it as a dependency to our project…
With that done we can immediately start making use of the tools that ship with the library.
There is essentially one main tool provided with the library, and it is a property wrapper called @Shared
. It can pretty much be used anywhere, including observable models:
@Observable
class CounterModel {
…
@Shared
}
It represents a piece of data that is shared with another part of the app, or possibly even with an external system, such as user defaults and the file system.
The @Shared
property wrapper can be used in a bare fashion like this:
@Shared var count: Int
…to represent a shared integer. This allows this single integer value to be shared with multiple parts of an application. You can kind of think of it like SwiftUI’s Binding
, except it works outside of views too.
But the real power of Shared
comes with providing an argument to the property wrapper which represents a strategy for persisting this value to an external system, as well as loading it from an external system and even observing changes to the external system so that the state in the app immediately updates.
There are 3 strategies that come with the library:
@Shared(.appStorage(<#key: String#>))
@Shared(.fileStorage(<#url: URL#>))
@Shared(.inMemory(<#key: String#>))
The appStorage
strategy will persist simple values to user defaults based on a string key provided. The fileStorage
strategy will persist more complex values to the file system by serializing and deserializing to bytes. And the inMemory
strategy shares a piece of state globally with the entire app, but it will be cleared out when the app is restarted.
Each of these have their use cases, and it’s even possible to define your own custom strategies. You can interface with any external system you want, whether that be SQLite, a remote server, or really anything your imagination can come up with.
But the thing we are most interested in right now is the appStorage
strategy since that is what we explored in vanilla SwiftUI just a moment ago. As we mentioned a moment ago, the @Shared
property is perfect fine to use in observable models, and so we can simply swap out the @AppStorage
we tried using for @Shared
:
@ObservationIgnored
// @AppStorage("count") var count = 0
@Shared(.appStorage("count")) var count = 0
This change does cause an error in our view where we mutate the count:
Setter for ‘count’ is unavailable Use ‘$shared.withLock’ to modify a shared value with exclusive access.
Direct write access to a shared value is unfortunately not allowed. Instead, you must go through a dedicated withLock
method on the projected value of @Shared
:
model.$count.withLock { $0 -= 1 }
…which attains a lock to perform the mutation.
It may seem like a bummer that this is necessary, but it’s important to understand why. First of all, the reason that @AppStorage
does not have this problem is because it only works in views, and views are main actor bound. This means, the vast majority of time you are interacting with @AppStorage
on the main thread, and so there are no potential synchronization issues.
It technically is possible to run into race conditions with @AppStorage
, but you have to go out of your way. If you were to fire up a task group and add a bunch of tasks to mutate the @AppStorage
, you will run into data loss.
Here’s a very simple view that demonstrates this:
struct AppStorageRaceCondition: View {
@AppStorage("racey-count") var count = 0
var body: some View {
Form {
Text("\(count)")
Button("Race!") {
Task {
await withTaskGroup(of: Void.self) { [_count] group in
for _ in 1...1_000 {
group.addTask {
_count.wrappedValue += 1
}
}
}
}
}
}
}
}
#Preview("AppStorage race condition") {
AppStorageRaceCondition()
}
Each time we tap the “Race!” button we would expect the count to go up by 1,000, but in reality it goes up far less.
So, this may seem bad, but also we did have to go out of our way to write this code. Notice that we were forced to capture the property wrapper value itself in the task group:
await withTaskGroup(of: Void.self) { [_count] group in
…
}
We had to do this because accessing self.count
in the task group is not allowed:
count += 1
Main actor-isolated property ‘count’ can not be mutated from a nonisolated context
Since views are @MainActor
bound we cannot access its properties with await
ing. And one thing we can technically do to work around this is capture the actual AppStorage
property wrapper value and mutating its wrappedValue
. The compiler allows this because the AppStorage
type is Sendable
, but mutating the wrappedValue
in this way:
_count.wrappedValue += 1
…is race-y.
The AppStorage
type must be performing synchronization of its own under the hood with a lock, and this transformation is a multi-step process: we access the wrappedValue
, we increment that value, and we set the wrappedValue
. The first an last of those steps are locked, and so we never corrupt our data, but if multiple threads are executing those steps they can interleave.
Of course another approach to this problem without reaching out to _count
would be to use MainActor.run
to synchronize to the main thread, and then we can increment count
directly:
group.addTask {
await MainActor.run {
count += 1
}
}
This fixes the race condition, but it is of course more work and we have to be aware of these subtleties of @AppStorage
.
And this is why we only expose a withLock
method on shared and do not allow direct mutation. The problem we are seeing with @AppStorage
would also affect @Shared
, but it would be even worse because @Shared
can be accessed from anywhere in your app. That includes SwiftUI views and observable models, but also even UIKit view controllers and any random little helper in your code base, and more. We do not have the benefit of knowing that the vast majority of interactions with our tools happens on the main thread, and so we really have no choice but to enforce synchronization in a more public manner.
And when we put our tool:
model.$count.withLock { $0 -= 1 }
…side-by-side with what we had to do in vanilla SwiftUI:
await MainActor.run {
count += 1
}
…we see that it isn’t really that different. In order to prevent race conditions in vanilla SwiftUI we need to provide synchronization to the main thread, and similarly to prevent race conditions with our tool we require synchronization using withLock
. So, overall we feel it’s a small price to pay for the safety and flexibility the tool offers.
With that done, our preview now works how we expect. Tapping the “Increment” and “Decrement” button does just that. It updates the count. Remember that this was completely broken when we moved the @AppStorage
count to the model, but our @Shared
tool works just fine from the model. And we can show that the withLock
tool really does help prevent races by writing the equivalent “Race” button for our @Shared
value:
Button("Race!") {
Task {
await withTaskGroup(
of: Void.self
) { [sharedCount = model.$count] group in
for _ in 1...1_000 {
group.addTask {
sharedCount.withLock { $0 += 1 }
}
}
}
}
}
Tapping the “Race!” button increments the count by 1,000, every time.
We can also run the app in the simulator to see that persistence works correctly. We can increment a few times, and then restart the app to see that the count was restored to exactly where it was last time the app was open.
But things get better. This tools also listens for changes to the key specified in user defaults so that it can update the state in the app automatically. This is how @AppStorage
works too, but again, our tool works anywhere, not just in views.
To explore this, let’s put in our ManyCountersView
for the entry point of the app:
WindowGroup {
ManyCountersView()
// CounterView()
}
Now when we run in the simulator we see two versions of our counter. Incrementing one immediately increments the other. And further, tapping the “Reset” button, which remember writes directly to user defaults:
Button("Reset") {
UserDefaults.standard.set(0, forKey: "count")
}
…immediately resets both counts to 0. And so for all intents and purposes it seems that our @Shared
property wrapper behaves just like @AppStorage
, except it works beautifully in observable models.
But things get better. Remember how in vanilla SwiftUI it was not possible to animate changes to @AppStorage
? That was happening because setting user defaults incurred a thread hop before the @AppStorage
was updated, and so wrapping that mutation in withAnimation
had no affect whatsoever. Well, that problem does not plague our @Shared
property wrapper. We can wrap that mutation in withAnimation
:
Button("Reset") {
withAnimation {
UserDefaults.standard.set(0, forKey: "count")
}
}
…run the app in the simulator again, and we will see that when we reset the count back to zero it happens with an animation.
And the same is true if we animate changes to the @Shared
count directly:
Button("Reset") {
withAnimation {
$count.withLock { $0 = 0 }
}
}
It’s a small detail for sure, but it just means that now have more control over how state changes in our app. We can have a completely decoupled part of our app, one that doesn’t even know about the @Shared
property wrapper, make a change to user defaults, and animate those changes. And that’s incredible.
And if you are interested in our theory as to why our @Shared
property wrapper does not have this animation problem but SwiftUI’s @AppStorage
does, well buckle up. Our theory is that the @AppStorage
property wrapper is using the NotificationCenter
to observe changes to user defaults, but NotificationCenter
does not let one listen for changes to a specific key in user defaults. You can only listen to the firehose of all changes to all keys in user defaults.
That seems bad, but it also seems that @AppStorage
does some extra work to de-duplicate observed changes so that when unrelated parts of user defaults changes it does not cause the view to re-compute its body. However, even so, user default changes can be emitted at basically any moment, and it’s even possible to happen while a view body is in the middle of computing. This leads to a situation where a view’s dependencies are changed while its computing its view, and that can lead to a SwiftUI crash.
The easy fix is to simply not update @AppStorage
’s state the moment a notification is posted, and instead enqueue the work to be done on the main thread after everything else on the main thread has finished. And that is why we believe animations do not occur when mutating user defaults directly.
So, then the question is: why is @AppStorage
subscribing to user defaults changes via NotificationCenter
rather than using key-value observing, which allows you to observe changes to a specific key rather than the firehose of user defaults changes? Well, unfortunately observing user defaults with KVO does not allow your string keys to have periods (.
) or at signs (@
) because they are reserved for special operations. And so perhaps the SwiftUI team took that as a non-starter and decided to use NotificationCenter
instead.
We on the other hand decided to take a bit of a different approach. If your app storage key does not contain a period or @-symbol, we use KVO to observe changes to that key. If your key does contain a period or @-symbol, we report an issue to let you know of the potential problems with that, and we use NotificationCenter
for observation. And further, if you really want to use special characters in your key, we also allow that warning to be turned off.
And our choice to support KVO for key changes has the added benefit of immediately getting observation changes across processes. This means the @Shared
property wrapper will also work when used in multiple processes, such as widgets and extensions, where @AppStorage
does not.
We have now see the basics of using the @Shared
property wrapper in a SwiftUI app, and in particular the appStorage
persistence strategy that comes with the library. It gives you a tool that is similar to the @AppStorage
property wrapper from SwiftUI, but it works outside of views, including observable models. And we even saw that it improves upon some key aspects, such as being animatable.
And so this is all looking great, but it gets better. The @Shared
property wrapper can be used in many more places than just observable models. It can be used in a SwiftUI view just like vanilla SwiftUI’s @AppStorage
, but it can be used in UIKit view controllers. And it behaves the same in all these different contexts, including automatically re-computing the view when the state changes, as well as listening for changes to user defaults directly so that it can update its state.
Let’s explore this, and more…next time!