We are excited to announce Sharing 2, an update to our popular library that introduces brand new tools for error handling and asynchrony.
The @Shared
property wrapper allows you to almost magically share state among your app’s features and various persistence layers, like user defaults, the file system, and even external APIs:
@Shared(.todos) var todos: [Todo] = []
But prior to 2.0, library users had very little insight into the interactions made between the shared property and its backing strategy, including the status of loading a value, as well as any errors that may have occurred.
Sharing 2.0 comes with a suite of new APIs that allow you to communicate these states to your users.
For example, the property wrapper’s load()
method is now asynchronous and throwing, which means you can instantly tie loading a value to SwiftUI’s refreshable view modifier:
.refreshable {
try? await $todos.load()
}
And load/error states are available directly on the projected value, giving you fine-grained control over your UI:
if $todos.isLoading {
ProgressView()
} else if let loadError = $todos.loadError {
ContentUnavailableView {
Label(
"Failed to load todos",
systemImage: "checkmark.circle.badge.xmark"
)
} description: {
Text(loadError.localizedDescription)
}
} else {
// ...
}
For a full, working example, see the repo’s new API Client Demo.
Sharing’s persistence strategies are powered by the SharedKey
and SharedReaderKey
protocols, and both have been revamped to allow for error handling and concurrency in their requirements: load
, subscribe
, and save
:
The load
requirement of SharedReaderKey
in 1.0 was as simple as this:
func load(initialValue: Value?) -> Value?
Its only job was to return an optional Value
that represent loading the value from the external storage system (e.g., user defaults, file system, etc.). However, there were a few problems with this signature:
It does not allow for asynchronous or throwing work to load the data.
The
initialValue
argument is not very descriptive and it wasn’t clear what it represented.It wasn’t clear why
load
returned an optional, nor was it clear what would happen if one returnednil
.
These problems are all fixed with the following updated signature for load
in SharedReaderKey
:
func load(
context: LoadContext<Value>,
continuation: LoadContinuation<Value>
)
This fixes the above 3 problems in the following way:
One can now load the value asynchronously, and when the value is finished loading it can be fed back into the shared state by invoking a
resume
method onLoadContinuation
. Further, there is aresume(throwing:)
method for emitting a loading error.The
context
argument knows the manner in which thisload
method is being invoked, i.e. the value is being loaded implicitly by initializing the@Shared
property wrapper, or the value is being loaded explicitly by invokingload()
.The
LoadContinuation
makes explicit the various ways one can resume when the load is complete. You can either invokeresume(returning:)
if a value successfully loaded, or invokeresume(throwing:)
if an error occurred, or invokeresumeReturningInitialValue()
if no value was found in the external storage and you want to use the initial value provided to@Shared
when it was created.
The subscribe
requirement of SharedReaderKey
has undergone changes similar to load
. In 1.0 the requirement was defined like so:
func subscribe(
initialValue: Value?,
didSet receiveValue: @escaping @Sendable (Value?) -> Void
) -> SharedSubscription
This allows a conformance to subscribe to changes in the external storage system, and when a change occurs it can replay that change back to @Shared
state by invoking the receiveValue
closure.
This method has many of the same problems as load
, such as confusion of what initialValue
represents and what nil
represents for the various optionals, as well as the inability to throw errors when something goes wrong during the subscription.
These problems are all fixed with the new signature:
func subscribe(
context: LoadContext<Value>,
subscriber: SharedSubscriber<Value>
) -> SharedSubscription
This new version of subscribe
is handed the LoadContext
that lets you know the context of the subscription’s creation, and the SharedSubscriber
allows you to emit errors by invoking the yield(throwing:)
method.
And finally, save
also underwent some changes that are similar to load
and subscribe
. Its prior form looked like this:
func save(_ value: Value, immediately: Bool)
This form has the problem that it does not support asynchrony or error throwing, and there was confusion of what immediately
meant. That boolean was intended to communicate to the implementor of this method that the value should be saved right away, and not be throttled.
The new form of this method fixes these problems:
func save(
_ value: Value,
context: SaveContext,
continuation: SaveContinuation
)
The SaveContext
lets you know if the save
is being invoked merely because the value of the @Shared
state changed, or because of user initiation by explicitly invoking save()
. It is the latter case that you may want to bypass any throttling logic and save the data immediately.
And the SaveContinuation
allows you to perform the saving logic asynchronously by resuming it after the saving work has finished. You can either invoke resume()
to indicate that saving finished successfully, or resume(throwing:)
to indicate that an error occurred.
If you are using Sharing’s built-in strategies, including appStorage
, fileStorage
, and inMemory
, Sharing 2.0 is for the most part a backwards-compatible update, with a few exceptions related to new functionality that mostly affect third-party persistence strategies. Be sure to check out the migration guide for more information.
The Composable Architecture has also been updated to support the full range of Swift Sharing 0.1.0 through 2.0. This means you get to decide which version of Swift Sharing you want to use with the Composable Architecture. Be sure to check out the migration guide for more information.
Sharing 2.0 is available to use in your projects today. Simply update your dependency to the latest release.