Monday Feb 26, 2024
Today we are excited to announce a new beta for the Composable Architecture.
Past betas have included the concurrency beta, the
Reducer
protocol beta, and most recently the
observation beta. And we think this one may be more exciting than all of those!
This beta focuses on providing the tools necessary for sharing state throughout your app. We also went above and beyond by providing tools for persisting state to user defaults and the file system, as well as providing a way create your own persistence strategies.
Join us for a quick overview of the tools, and be sure to check out the beta today!
The primary tool provided in this beta is the @Shared
property wrapper. It
represents a piece of state that will be shared with another part of the application, or potentially
with the entire application. It can be used with any data type, and it cannot be set to a default
value.
For example, suppose you have a feature that holds a count and you want to be able to hand a shared
reference to that count to other features. You can do so by holding onto a @Shared
property in the
feature’s state:
@Reducer
struct ParentFeature {
@ObservableState
struct State {
@Shared var count: Int
// Other properties
}
// ...
}
It is not possible to provide a default to a @Shared
value. It must be passed to this feature’s state from a parent feature.
Then suppose that this feature can present a child feature that wants access to this shared count
value. The child feature would also would hold onto an @Shared
property to a count:
@Reducer
struct ChildFeature {
@ObservableState
struct State {
@Shared var count: Int
// Other properties
}
// ...
}
When the parent features creates the child feature’s state, it can pass a reference to the shared
count rather than the actual count value by using the $count
projected value:
case .presentButtonTapped:
state.child = ChildFeature.State(count: state.$count)
// ...
Now any mutation the ChildFeature
makes to its count
will be instantly made to the
ParentFeature
’s count too, and vice-versa.
The Shared
type works by holding onto a reference type so that multiple parts of the application
can see the same state and can each make mutations with it. Historically, reference types in state
were problematic for two main reasons:
Equatable
,
and even when they do you cannot compare them before and after a mutation is made in order to
exhaustively prove how it changes.However, there are now solutions to both of these problems:
@Shared
property wrapper, we are able to make shared test 100% testable, even
exhaustively! When using the TestStore
to test your features you will be forced to assert on
how all state changes in your feature, even state held in @Shared
.The @Shared
property wrapper can also be used in conjunction with a persistence strategy that
makes the state available globally throughout the entire application, and persists the data
to some external system so that it can be made available across application launches.
For example, to save the count
described above in user defaults so that any changes are
automatically persisted and made available next time the app launches, simply use the .appStorage
persistence strategy with @Shared
:
@Reducer
struct ParentFeature {
@ObservableState
struct State {
- @Shared var count: Int
+ @Shared(.appStorage("count")) var count = 0
// Other properties
}
// ...
}
You must provide a default value when using a persistence strategy. It is only used upon first access of the state and when there is no previously saved state (for example, the first launch of the app).
That’s all it takes. Now any part of the application can instantly access this state by using
the same @Shared
configuration, and it does not even need to be explicitly passed in from the
parent feature. Any changes made to this state will be immediately persisted to user defaults, and
further if something writes to the “count” key in user defaults directly without going through
@Shared
, the state in your feature will be immediately updated too.
The .appStorage
persistence strategy is limited by the kinds of data you can store in it since
user defaults has those limitations. It’s mostly appropriate for very simple data, such as strings,
integers, booleans, etc.
There is also a .fileStorage
strategy you can use to persist data. It requires that your state
is Codable
, and it’s more appropriate for complex data types rather than simpler values. We use
this kind of persistence in our SyncUps demo application for persisting
the list of sync up meetings to disk:
@ObservableState
struct State: Equatable {
@Presents var destination: Destination.State?
@Shared(.fileStorage(.syncUps)) var syncUps: IdentifiedArrayOf<SyncUp> = []
}
This persistence strategy behaves like .appStorage
in many ways. Using it makes the state
globally available to all parts of the application, and any change to the state will be persisted
to disk. Further, if the data on disk is changed outside of @Shared
, that change will be
immediately played back to any feature holding onto @Shared
.
There is a third form of persistence that comes with the library called .inMemory
. It has no
restrictions on the kind of value you can hold in it, but that’s only because it doesn’t actually
persist the data anywhere. It just makes the data globally available in your application, and it
will be cleared out between app launches. It is similar to the “in-memory”
persistence storage from SwiftData.
It is even possible for you to create your own persistence strategies! You can simply conform a new
type to the PersistentKey
protocol, implement a few requirements, and then
it will be available to use with @Shared
.
Shared state behaves quite a bit different from the regular state held in Composable Architecture features. It is capable of being changed by any part of the application, not just when an action is sent to the store, and it has reference semantics rather than value semantics. Typically references cause series problems with testing, especially exhaustive testing that the library prefers (see Testing), because references cannot be copied and so one cannot inspect the changes before and after an action is sent.
For this reason, the @Shared
property wrapper does extra working during testing to preserve a
previous snapshot of the state so that one can still exhaustively assert on shared state, even
though it is a reference.
For the most part, shared state can be tested just like any regular state held in your features. For example, consider the following simple counter feature that uses in-memory shared state for the count:
@Reducer
struct Feature {
struct State: Equatable {
@Shared var count: Int
}
enum Action {
case incrementButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
}
}
}
}
This feature can be tested in exactly the same way as when you are using non-shared state:
func testIncrement() async {
let store = TestStore(initialState: Feature.State(count: Shared(0))) {
Feature()
}
await store.send(.incrementButtonTapped) {
$0.count = 1
}
}
This test passes because we have described how the state changes. But even better, if we mutate the
count
incorrectly:
func testIncrement() async {
let store = TestStore(initialState: Feature.State(count: Shared(0))) {
Feature()
}
await store.send(.incrementButtonTapped) {
$0.count = 2
}
}
…we immediately get a test failure letting us know exactly what went wrong:
❌ State was not expected to change, but a change occurred: …
− Feature.State(_count: 2)
+ Feature.State(_count: 1)
(Expected: −, Actual: +)
This works even though the @Shared
count is a reference type. The TestStore
and Shared
type work in unison to snapshot the state before and after the action is sent, allowing us to still
assert in an exhaustive manner.
However, exhaustively testing shared state is more complicated than testing non-shared state in features. Shared state can be captured in effects and mutated directly, without ever sending an action into system. This is in stark contrast to regular state, which can only ever be mutated when sending an action.
For example, it is possible to alter the incrementButtonTapped
action so that it captures the
shared state in an effect, and then increments from the effect:
case .incrementButtonTapped:
return .run { [count = state.$count] _ in
count.wrappedValue += 1
}
The only reason this is possible is because @Shared
state is reference-like, and hence can
technically be mutated from anywhere.
However, how does this affect testing? Since the count
is no longer incremented directly in
the reducer we can drop the trailing closure from the test store assertion:
func testIncrement() async {
let store = TestStore(initialState: SimpleFeature.State(count: Shared(0))) {
SimpleFeature()
}
await store.send(.incrementButtonTapped)
}
This is technically correct, but we aren’t testing the behavior of the effect at all.
Luckily the TestStore
has our back. If you run this test you will immediately get a failure
letting you know that the shared count was mutated but we did not assert on the changes:
❌ Tracked changes to 'Shared<Int>@MyAppTests/FeatureTests.swift:10' but failed to assert: …
− 0
+ 1
(Before: −, After: +)
Call 'Shared<Int>.assert' to exhaustively test these changes, or call 'skipChanges' to ignore them.
In order to get this test passing we have to explicitly assert on the shared counter state at
the end of the test, which we can do using the Shared/assert(_:file:line:)
method:
func testIncrement() async {
let store = TestStore(initialState: SimpleFeature.State(count: Shared(0))) {
SimpleFeature()
}
await store.send(.incrementButtonTapped)
store.state.$count.assert {
$0 = 1
}
}
Now the test passes.
So, even though the @Shared
type opens our application up to a little bit more uncertainty due
to its reference semantics, it is still possible to get exhaustive test coverage on its changes.
We are very excited about these new shared state tools in the Composable Architecture, and we would
love to get your feedback on it. Please consider pointing your project to the shared-state-beta
branch and letting us know if anything goes wrong. Also be sure to read the
Sharing State article and 1.9 migration guide for
more information on the tools.
👋 Hey there! If you got this far, then you must have enjoyed this post. You may want to also check out Point-Free, a video series covering advanced programming topics in Swift. Consider subscribing today!