mirror of
https://github.com/pointfreeco/swift-composable-architecture.git
synced 2025-12-20 09:11:33 +01:00
* Testing: Case Studies * Testing: Search * Testing: Speech Recognition * Testing: SyncUps * Project Settings * Testing: TicTacToe * Testing: Todos * Testing: Voice Memos * Update tutorials/docs * fix * Fixes * wip * Update ci.yml * Update ci.yml * Update ci.yml * wip * wip * Quiet * fix * wip * wip * flaky * wip * wip * fix
1190 lines
42 KiB
Markdown
1190 lines
42 KiB
Markdown
# Sharing state
|
||
|
||
Learn techniques for sharing state throughout many parts of your application, and how to persist
|
||
data to user defaults, the file system, and other external mediums.
|
||
|
||
## Overview
|
||
|
||
Sharing state is the process of letting many features have access to the same data so that when any
|
||
feature makes a change to this data it is instantly visible to every other feature. Such sharing can
|
||
be really handy, but also does not play nicely with value types, which are copied rather than
|
||
shared. Because the Composable Architecture highly prefers modeling domains with value types rather
|
||
than reference types, sharing state can be tricky.
|
||
|
||
This is why the library comes with a few tools for sharing state with many parts of your
|
||
application. There are two main kinds of shared state in the library: explicitly passed state and
|
||
persisted state. And there are 3 persistence strategies shipped with the library:
|
||
[in-memory](<doc:PersistenceReaderKey/inMemory(_:)>),
|
||
[user defaults](<doc:PersistenceReaderKey/appStorage(_:)-4l5b>), and
|
||
[file storage](<doc:PersistenceReaderKey/fileStorage(_:decoder:encoder:)>). You can also implement
|
||
your own persistence strategy if you want to use something other than user defaults or the file
|
||
system, such as SQLite.
|
||
|
||
* ["Source of truth"](#Source-of-truth)
|
||
* [Explicit shared state](#Explicit-shared-state)
|
||
* [Persisted shared state](#Persisted-shared-state)
|
||
* [In-memory](#In-memory)
|
||
* [User defaults](#User-defaults)
|
||
* [File storage](#File-storage)
|
||
* [Custom persistence](#Custom-persistence)
|
||
* [Observing changes to shared state](#Observing-changes-to-shared-state)
|
||
* [Initialization rules](#Initialization-rules)
|
||
* [Deriving shared state](#Deriving-shared-state)
|
||
* [Testing](#Testing)
|
||
* [Testing when using persistence](#Testing-when-using-persistence)
|
||
* [Testing when using custom persistence strategies](#Testing-when-using-custom-persistence-strategies)
|
||
* [Overriding shared state in tests](#Overriding-shared-state-in-tests)
|
||
* [UI Testing](#UI-Testing)
|
||
* [Testing tips](#Testing-tips)
|
||
* [Read-only shared state](#Read-only-shared-state)
|
||
* [Type-safe keys](#Type-safe-keys)
|
||
* [Concurrent mutations to shared state](#Concurrent-mutations-to-shared-state)
|
||
* [Shared state in pre-observation apps](#Shared-state-in-pre-observation-apps)
|
||
* [Gotchas of @Shared](#Gotchas-of-Shared)
|
||
|
||
## "Source of truth"
|
||
|
||
First a quick discussion on defining exactly what "shared state" is. A common concept thrown around
|
||
in architectural discussions is "single source of truth." This is the idea that the complete state
|
||
of an application, even its navigation, can be driven off a single piece of data. It's a great idea,
|
||
in theory, but in practice it can be quite difficult to completely embrace.
|
||
|
||
First of all, a _single_ piece of data to drive _all_ of application state is just not feasible.
|
||
There is a lot of state in an application that is fine to be local to a view and does not need
|
||
global representation. For example, the state of whether a button is being pressed is probably fine
|
||
to reside privately inside the button.
|
||
|
||
And second, applications typically do not have a _single_ source of truth. That is far too
|
||
simplistic. If your application loads data from an API, or from disk, or from user defaults, then
|
||
the "truth" for that data does not lie in your application. It lies externally.
|
||
|
||
In reality, there are _two_ sources of "truth" in any application. There is the state the
|
||
application needs to execute its logic and behavior. This is the kind of state that determines if a
|
||
button is enabled or disabled, drives navigation such as sheets and drill-downs, and handles
|
||
validation of forms. Such state only makes sense for the application.
|
||
|
||
Then there is a second source of "truth" in an application, which is the data that lies in some
|
||
external system and needs to be loaded into the application. Such state is best modeled as a
|
||
dependency or using the shared state tools discussed in this article.
|
||
|
||
## Explicit shared state
|
||
|
||
This is the simplest kind of shared state to get started with. It allows you to share state amongst
|
||
many features without any persistence. The data is only held in memory, and will be cleared out the
|
||
next time the application is run.
|
||
|
||
To share data in this style, use the [`@Shared`](<doc:Shared>) property wrapper with no arguments.
|
||
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:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct ParentFeature {
|
||
@ObservableState
|
||
struct State {
|
||
@Shared var count: Int
|
||
// Other properties
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
> Important: It is not possible to provide a default to a `@Shared` value. It must be passed to the
|
||
> feature's state from the outside. See <doc:SharingState#Initialization-rules> for more
|
||
> information about how to initialize types that use `@Shared`.
|
||
|
||
Then suppose that this feature can present a child feature that wants access to this shared `count`
|
||
value. It too would hold onto a `@Shared` property to a count:
|
||
|
||
```swift
|
||
@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` ``Shared/projectedValue``:
|
||
|
||
```swift
|
||
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.
|
||
|
||
## Persisted shared state
|
||
|
||
Explicitly shared state discussed above is a nice, lightweight way to share a piece of data with
|
||
many parts of your application. However, sometimes you want to share state with the entire
|
||
application without having to pass it around explicitly. One can do this by passing a
|
||
``PersistenceKey`` to the `@Shared` property wrapper, and the library comes with three persistence
|
||
strategies, as well as the ability to create custom persistence strategies.
|
||
|
||
#### In-memory
|
||
|
||
This is the simplest persistence strategy in that it doesn't actually persist at all. It keeps
|
||
the data in memory and makes it available to every part of the application, but when the app is
|
||
relaunched the data will be reset back to its default.
|
||
|
||
It can be used by passing ``PersistenceReaderKey/inMemory(_:)`` to the `@Shared` property wrapper.
|
||
For example, suppose you want to share an integer count value with the entire application so that
|
||
any feature can read from and write to the integer. This can be done like so:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct ChildFeature {
|
||
@ObservableState
|
||
struct State {
|
||
@Shared(.inMemory("count")) var count = 0
|
||
// Other properties
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
> Note: When using a persistence strategy with `@Shared` you must provide a default value, which is
|
||
> used for the first access of the shared state.
|
||
|
||
Now any part of the application can read from and write to this state, and features will never
|
||
get out of sync.
|
||
|
||
#### User defaults
|
||
|
||
If you would like to persist your shared value across application launches, then you can use the
|
||
``PersistenceReaderKey/appStorage(_:)-4l5b`` strategy with `@Shared` in order to automatically
|
||
persist any changes to the value to user defaults. It works similarly to in-memory sharing discussed
|
||
above. It requires a key to store the value in user defaults, as well as a default value that will
|
||
be used when there is no value in the user defaults:
|
||
|
||
```swift
|
||
@Shared(.appStorage("count")) var count = 0
|
||
```
|
||
|
||
That small change will guarantee that all changes to `count` are persisted and will be
|
||
automatically loaded the next time the application launches.
|
||
|
||
This form of persistence only works for simple data types because that is what works best with
|
||
`UserDefaults`. This includes strings, booleans, integers, doubles, URLs, data, and more. If you
|
||
need to store more complex data, such as custom data types serialized to JSON, then you will want
|
||
to use the [`.fileStorage`](<doc:SharingState#File-storage>) strategy or a
|
||
[custom persistence](<doc:SharingState#Custom-persistence>) strategy.
|
||
|
||
#### File storage
|
||
|
||
If you would like to persist your shared value across application launches, and your value is
|
||
complex (such as a custom data type), then you can use the ``PersistenceReaderKey/fileStorage(_:decoder:encoder:)``
|
||
strategy with `@Shared`. It automatically persists any changes to the file system.
|
||
|
||
It works similarly to the in-memory sharing discussed above, but it requires a URL to store the data
|
||
on disk, as well as a default value that will be used when there is no data in the file system:
|
||
|
||
```swift
|
||
@Shared(.fileStorage(URL(/* ... */)) var users: [User] = []
|
||
```
|
||
|
||
This strategy works by serializing your value to JSON to save to disk, and then deserializing JSON
|
||
when loading from disk. For this reason the value held in `@Shared(.fileStorage(…))` must conform to
|
||
`Codable`.
|
||
|
||
#### Custom persistence
|
||
|
||
It is possible to define all new persistence strategies for the times that user defaults or JSON
|
||
files are not sufficient. To do so, define a type that conforms to the ``PersistenceKey`` protocol:
|
||
|
||
```swift
|
||
public final class CustomPersistenceKey: PersistenceKey {
|
||
// ...
|
||
}
|
||
```
|
||
|
||
And then define a static function on the ``PersistenceKey`` protocol for creating your new
|
||
persistence strategy:
|
||
|
||
```swift
|
||
extension PersistenceReaderKey {
|
||
public static func custom<Value>(/*...*/) -> Self
|
||
where Self == CustomPersistence<Value> {
|
||
CustomPersistence(/* ... */)
|
||
}
|
||
}
|
||
```
|
||
|
||
With those steps done you can make use of the strategy in the same way one does for
|
||
``PersistenceReaderKey/appStorage(_:)-4l5b`` and ``PersistenceReaderKey/fileStorage(_:decoder:encoder:)``:
|
||
|
||
```swift
|
||
@Shared(.custom(/* ... */)) var myValue: Value
|
||
```
|
||
|
||
The ``PersistenceKey`` protocol represents loading from _and_ saving to some external storage,
|
||
such as the file system or user defaults. Sometimes saving is not a valid operation for the external
|
||
system, such as if your server holds onto a remote configuration file that your app uses to
|
||
customize its appearance or behavior. In those situations you can conform to the
|
||
``PersistenceReaderKey`` protocol. See <doc:SharingState#Read-only-shared-state> for more
|
||
information.
|
||
|
||
## Observing changes to shared state
|
||
|
||
The ``Shared`` property wrapper exposes a ``Shared/publisher`` property so that you can observe
|
||
changes to the reference from any part of your application. For example, if some feature in your
|
||
app wants to listen for changes to some shared `count` value, then it can introduce an `onAppear`
|
||
action that kicks off a long-living effect that subscribes to changes of `count`:
|
||
|
||
```swift
|
||
case .onAppear:
|
||
return .publisher {
|
||
state.$count.publisher
|
||
.map(Action.countUpdated)
|
||
}
|
||
|
||
case .countUpdated(let count):
|
||
// Do something with count
|
||
return .none
|
||
```
|
||
|
||
Note that you will have to be careful for features that both hold onto shared state and subscribe
|
||
to changes to that state. It is possible to introduce an infinite loop if you do something like
|
||
this:
|
||
|
||
```swift
|
||
case .onAppear:
|
||
return .publisher {
|
||
state.$count.publisher
|
||
.map(Action.countUpdated)
|
||
}
|
||
|
||
case .countUpdated(let count):
|
||
state.count = count + 1
|
||
return .none
|
||
```
|
||
|
||
If `count` changes, then `$count.publisher` emits, causing the `countUpdated` action to be sent,
|
||
causing the shared `count` to be mutated, causing `$count.publisher` to emit, and so on.
|
||
|
||
## Initialization rules
|
||
|
||
Because the state sharing tools use property wrappers there are special rules that must be followed
|
||
when writing custom initializers for your types. These rules apply to _any_ kind of property
|
||
wrapper, including those that ship with vanilla SwiftUI (e.g. `@State`, `@StateObject`, etc.),
|
||
but the rules can be quite confusing and so below we describe the various ways to initialize
|
||
shared state.
|
||
|
||
It is common to need to provide a custom initializer to your feature's
|
||
``Reducer/State`` type, especially when modularizing. When using
|
||
[`@Shared`](<doc:Shared>) in your `State` that can become complicated.
|
||
Depending on your exact situation you can do one of the following:
|
||
|
||
* You are using non-persisted shared state (i.e. no argument is passed to `@Shared`), and the
|
||
"source of truth" of the state lives with the parent feature. Then the initializer should take a
|
||
`Shared` value and you can assign through the underscored property:
|
||
|
||
```swift
|
||
public struct State {
|
||
@Shared public var count: Int
|
||
// other fields
|
||
|
||
public init(count: Shared<Int>, /* other fields */) {
|
||
self._count = count
|
||
// other assignments
|
||
}
|
||
}
|
||
```
|
||
|
||
* You are using non-persisted shared state (_i.e._ no argument is passed to `@Shared`), and the
|
||
"source of truth" of the state lives within the feature you are initializing. Then the initializer
|
||
should take a plain, non-`Shared` value and you construct the `Shared` value in the initializer:
|
||
|
||
```swift
|
||
public struct State {
|
||
@Shared public var count: Int
|
||
// other fields
|
||
|
||
public init(count: Int, /* other fields */) {
|
||
self._count = Shared(count)
|
||
// other assignments
|
||
}
|
||
}
|
||
```
|
||
|
||
* You are using a persistence strategy with shared state (_e.g._
|
||
``PersistenceReaderKey/appStorage(_:)-4l5b``, ``PersistenceReaderKey/fileStorage(_:decoder:encoder:)``, _etc._),
|
||
then the initializer should take a plain, non-`Shared` value and you construct the `Shared` value in
|
||
the initializer using ``Shared/init(wrappedValue:_:fileID:line:)-512rh`` which takes a
|
||
``PersistenceKey`` as the second argument:
|
||
|
||
```swift
|
||
public struct State {
|
||
@Shared public var count: Int
|
||
// other fields
|
||
|
||
public init(count: Int, /* other fields */) {
|
||
self._count = Shared(wrappedValue: count, .appStorage("count"))
|
||
// other assignments
|
||
}
|
||
}
|
||
```
|
||
|
||
The declaration of `count` can use `@Shared` without an argument because the persistence
|
||
strategy is specified in the initializer.
|
||
|
||
> Important: The value passed to this initializer is only used if the external storage does not
|
||
> already have a value. If a value exists in the storage then it is not used. In fact, the
|
||
> `wrappedValue` argument of ``Shared/init(wrappedValue:_:fileID:line:)-512rh`` is an
|
||
> `@autoclosure` so that it is not even evaluated if not used. For that reason you
|
||
> may prefer to make the argument to the initializer an `@autoclosure` so that it too is evaluated
|
||
> only if actually used:
|
||
>
|
||
> ```swift
|
||
> public struct State {
|
||
> @Shared public var count: Int
|
||
> // other fields
|
||
>
|
||
> public init(count: @autoclosure () -> Int, /* other fields */) {
|
||
> self._count = Shared(wrappedValue: count(), .appStorage("count"))
|
||
> // other assignments
|
||
> }
|
||
> }
|
||
> ```
|
||
|
||
## Deriving shared state
|
||
|
||
It is possible to derive shared state for sub-parts of an existing piece of shared state. For
|
||
example, suppose you have a multi-step signup flow that uses `Shared<SignUpData>` in order to share
|
||
data between each screen. However, some screens may not need all of `SignUpData`, but instead just a
|
||
small part. The phone number confirmation screen may only need access to `signUpData.phoneNumber`,
|
||
and so that feature can hold onto just `Shared<String>` to express this fact:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct PhoneNumberFeature {
|
||
struct State {
|
||
@Shared var phoneNumber: String
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
Then, when the parent feature constructs the `PhoneNumberFeature` it can derive a small piece of
|
||
shared state from `Shared<SignUpData>` to pass along:
|
||
|
||
```swift
|
||
case .nextButtonTapped:
|
||
state.path.append(
|
||
PhoneNumberFeature.State(phoneNumber: state.$signUpData.phoneNumber)
|
||
)
|
||
```
|
||
|
||
Here we are using the ``Shared/projectedValue`` value using `$` syntax, `$signUpData`, and then
|
||
further dot-chaining onto that projection to derive a `Shared<String>`. This can be a powerful way
|
||
for features to hold onto only the bare minimum of shared state it needs to do its job.
|
||
|
||
It can be instructive to think of `@Shared` as the Composable Architecture analogue of `@Bindable`
|
||
in vanilla SwiftUI. You use it to express that the actual "source of truth" of the value lies
|
||
elsewhere, but you want to be able to read its most current value and write to it.
|
||
|
||
This also works for persistence strategies. If a parent feature holds onto a `@Shared` piece of
|
||
state with a persistence strategy:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct ParentFeature {
|
||
struct State {
|
||
@Shared(.fileStorage(.currentUser)) var currentUser
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
…and a child feature wants access to just a shared _piece_ of `currentUser`, such as their name,
|
||
then they can do so by holding onto a simple, unadorned `@Shared`:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct ChildFeature {
|
||
struct State {
|
||
@Shared var currentUserName: String
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
And then the parent can pass along `$currentUser.name` to the child feature when constructing its
|
||
state:
|
||
|
||
```swift
|
||
case .editNameButtonTapped:
|
||
state.destination = .editName(
|
||
EditNameFeature(name: state.$currentUser.name)
|
||
)
|
||
```
|
||
|
||
Any changes the child feature makes to its shared `name` will be automatically made to the parent's
|
||
shared `currentUser`, and further those changes will be automatically persisted thanks to the
|
||
`.fileStorage` persistence strategy used. This means the child feature gets to describe that it
|
||
needs access to shared state without describing the persistence strategy, and the parent can be
|
||
responsible for persisting and deriving shared state to pass to the child.
|
||
|
||
If your shared state is a collection, and in particular an `IdentifiedArray`, then we have another
|
||
tool for deriving shared state to a particular element of the array. You can subscript into a
|
||
``Shared`` collection with the `[id:]` subscript, and that will give a piece of optional shared
|
||
state (thanks to a dynamic member overload ``Shared/subscript(dynamicMember:)-7ibhr``), which you
|
||
can then unwrap to turn into honest shared state:
|
||
|
||
```swift
|
||
@Shared(.fileStorage(.todos)) var todos: IdentifiedArrayOf<Todo> = []
|
||
|
||
guard let todo = $todos[id: todoID]
|
||
else { return }
|
||
todo // Shared<Todo>
|
||
```
|
||
|
||
There is another tool for deriving shared state, and it is the computed property ``Shared/elements``
|
||
that is defined on shared collections. It derives a collection of shared elements so that you can
|
||
get access to a shared reference of just one particular element in a collection.
|
||
|
||
However, it is only appropriate to use this in conjunction with `ForEach` in order to derive a
|
||
shared reference for each element of a collection:
|
||
|
||
```swift
|
||
struct State {
|
||
@Shared(.fileStorage(.todos)) var todos: IdentifiedArrayOf<Todo> = []
|
||
// ...
|
||
}
|
||
|
||
// ...
|
||
|
||
ForEach(store.$todos.elements) { $todo in
|
||
NavigationLink(
|
||
// $todo: Shared<Todo>
|
||
// todo: Todo
|
||
state: Path.State.todo(TodoFeature.State(todo: $todo))
|
||
) {
|
||
Text(todo.title)
|
||
}
|
||
}
|
||
```
|
||
|
||
> Important: We do not recommend using ``Shared/elements`` outside of using it with `ForEach`,
|
||
> `List`, and other SwiftUI views that take collections.
|
||
|
||
## Testing
|
||
|
||
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 serious problems with testing, especially exhaustive testing that the library prefers (see
|
||
<doc: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 work 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:
|
||
|
||
```swift
|
||
@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:
|
||
|
||
```swift
|
||
@Test
|
||
func increment() 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:
|
||
|
||
|
||
```swift
|
||
@Test
|
||
func increment() 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:
|
||
|
||
```swift
|
||
case .incrementButtonTapped:
|
||
return .run { [sharedCount = state.$count] _ in
|
||
await sharedCount.withLock { $0 += 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:
|
||
|
||
```swift
|
||
@Test
|
||
func increment() 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(_:fileID:file:line:column:)`` method:
|
||
|
||
```swift
|
||
@Test
|
||
func increment() 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.
|
||
|
||
#### Testing when using persistence
|
||
|
||
It is also possible to test when using one of the persistence strategies provided by the library,
|
||
which are ``PersistenceReaderKey/appStorage(_:)-4l5b`` and
|
||
``PersistenceReaderKey/fileStorage(_:decoder:encoder:)``. Typically persistence is difficult to test because the
|
||
persisted data bleeds over from test to test, making it difficult to exhaustively prove how each
|
||
test behaves in isolation.
|
||
|
||
But the `.appStorage` and `.fileStorage` strategies do extra work to make sure that happens. By
|
||
default the `.appStorage` strategy uses a non-persisting user defaults so that changes are not
|
||
actually persisted across test runs. And the `.fileStorage` strategy uses a mock file system so that
|
||
changes to state are not actually persisted to the file system.
|
||
|
||
This means that if we altered the `SimpleFeature` of the <doc:SharingState#Testing> section above to
|
||
use app storage:
|
||
|
||
```swift
|
||
struct State: Equatable {
|
||
@Shared(.appStorage("count")) var count: Int
|
||
}
|
||
````
|
||
|
||
…then the test for this feature can be written in the same way as before and will still pass.
|
||
|
||
#### Testing when using custom persistence strategies
|
||
|
||
When creating your own custom persistence strategies you must careful to do so in a style that
|
||
is amenable to testing. For example, the ``PersistenceReaderKey/appStorage(_:)-4l5b`` persistence
|
||
strategy that comes with the library injects a ``Dependencies/DependencyValues/defaultAppStorage``
|
||
dependency so that one can inject a custom `UserDefaults` in order to execute in a controlled
|
||
environment. By default ``Dependencies/DependencyValues/defaultAppStorage`` uses a non-persisting
|
||
user defaults, but you can also customize it to use any kind of defaults.
|
||
|
||
Similarly the ``PersistenceReaderKey/fileStorage(_:decoder:encoder:)`` persistence strategy uses an internal
|
||
dependency for changing how files are written to the disk and loaded from disk. In tests the
|
||
dependency will forgo any interaction with the file system and instead write data to a `[URL: Data]`
|
||
dictionary, and load data from that dictionary. That emulates how the file system works, but without
|
||
persisting any data to the global file system, which can bleed over into other tests.
|
||
|
||
#### Overriding shared state in tests
|
||
|
||
When testing features that use `@Shared` with a persistence strategy you may want to set the initial
|
||
value of that state for the test. Typically this can be done by declaring the shared state at
|
||
the beginning of the test so that its default value can be specified:
|
||
|
||
```swift
|
||
@Test
|
||
func basics() {
|
||
@Shared(.appStorage("count")) var count = 42
|
||
|
||
// Shared state will be 42 for all features using it.
|
||
let store = TestStore(…)
|
||
}
|
||
```
|
||
|
||
However, if your test suite is a part of an app target, then the entry point of the app will execute
|
||
and potentially cause an early access of `@Shared`, thus capturing a different default value than
|
||
what is specified above. This quirk of tests in app targets is documented in
|
||
<doc:Testing#Testing-gotchas> of the <doc:Testing> article, and a similar quirk exists for Xcode
|
||
previews and is discussed below in <doc:SharingState#Gotchas-of-Shared>.
|
||
|
||
The most robust workaround to this issue is to simply not execute your app's entry point when tests
|
||
are running, which we detail in <doc:Testing#Testing-host-application>. This makes it so that you
|
||
are not accidentally execute network requests, tracking analytics, etc. while running tests.
|
||
|
||
You can also work around this issue by simply setting the shared state again after initializing
|
||
it:
|
||
|
||
```swift
|
||
@Test
|
||
func basics() {
|
||
@Shared(.appStorage("count")) var count = 42
|
||
count = 42 // NB: Set again to override any value set by the app target.
|
||
|
||
// Shared state will be 42 for all features using it.
|
||
let store = TestStore(…)
|
||
}
|
||
```
|
||
|
||
#### UI Testing
|
||
|
||
When UI testing your app you must take extra care so that shared state is not persisted across
|
||
app runs because that can cause one test to bleed over into another test, making it difficult to
|
||
write deterministic tests that always pass. To fix this, you can set an environment value from
|
||
your UI test target, and then if that value is present in the app target you can override the
|
||
``Dependencies/DependencyValues/defaultAppStorage`` and
|
||
``Dependencies/DependencyValues/defaultFileStorage`` dependencies so that they use in-memory
|
||
storage, i.e. they do not persist ever:
|
||
|
||
```swift
|
||
@main
|
||
struct EntryPoint: App {
|
||
let store = Store(initialState: AppFeature.State()) {
|
||
AppFeature()
|
||
} withDependencies: {
|
||
if ProcessInfo.processInfo.environment["UITesting"] == "true" {
|
||
$0.defaultAppStorage = UserDefaults(
|
||
suiteName:"\(NSTemporaryDirectory())\(UUID().uuidString)"
|
||
)!
|
||
$0.defaultFileStorage = .inMemory
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Testing tips
|
||
|
||
There is something you can do to make testing features with shared state more robust and catch
|
||
more potential future problems when you refactor your code. Right now suppose you have two features
|
||
using `@Shared(.appStorage("count"))`:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct Feature1 {
|
||
struct State {
|
||
@Shared(.appStorage("count")) var count = 0
|
||
}
|
||
// ...
|
||
}
|
||
|
||
@Reducer
|
||
struct Feature2 {
|
||
struct State {
|
||
@Shared(.appStorage("count")) var count = 0
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
And suppose you wrote a test that proves one of these counts is incremented when a button is tapped:
|
||
|
||
```swift
|
||
await store.send(.feature1(.buttonTapped)) {
|
||
$0.feature1.count = 1
|
||
}
|
||
```
|
||
|
||
Because both features are using `@Shared` you can be sure that both counts are kept in sync, and
|
||
so you do not need to assert on `feature2.count`.
|
||
|
||
However, if someday during a long, complex refactor you accidentally removed `@Shared` from
|
||
the second feature:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct Feature2 {
|
||
struct State {
|
||
var count = 0
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
…then all of your code would continue compiling, and the test would still pass, but you may have
|
||
introduced a bug by not having these two pieces of state in sync anymore.
|
||
|
||
You could also fix this by forcing yourself to assert on all shared state in your features, even
|
||
though technically it's not necessary:
|
||
|
||
```swift
|
||
await store.send(.feature1(.buttonTapped)) {
|
||
$0.feature1.count = 1
|
||
$0.feature2.count = 1
|
||
}
|
||
```
|
||
|
||
If you are worried about these kinds of bugs you can make your tests more robust by not asserting
|
||
on the shared state in the argument handed to the trailing closure of ``TestStore``'s `send`, and
|
||
instead capture a reference to the shared state in the test and mutate it in the trailing
|
||
closure:
|
||
|
||
|
||
```swift
|
||
@Test
|
||
func increment() async {
|
||
@Shared(.appStorage("count")) var count = 0
|
||
let store = TestStore(initialState: ParentFeature.State()) {
|
||
ParentFeature()
|
||
}
|
||
|
||
await store.send(.feature1(.buttonTapped)) {
|
||
// Mutate $0 to expected value.
|
||
count = 1
|
||
}
|
||
}
|
||
```
|
||
|
||
This will fail if you accidentally remove a `@Shared` from one of your features.
|
||
|
||
Further, you can enforce this pattern in your codebase by making all `@Shared` properties
|
||
`fileprivate` so that they can never be mutated outside their file scope:
|
||
|
||
```swift
|
||
struct State {
|
||
@Shared(.appStorage("count")) fileprivate var count = 0
|
||
}
|
||
```
|
||
|
||
## Read-only shared state
|
||
|
||
The [`@Shared`](<doc:Shared>) property wrapper described above gives you access to a piece of shared
|
||
state that is both readable and writable. That is by far the most common use case when it comes to
|
||
shared state, but there are times when one wants to express access to shared state for which you
|
||
are not allowed to write to it, or possibly it doesn't even make sense to write to it.
|
||
|
||
For those times there is the [`@SharedReader`](<doc:SharedReader>) property wrapper. It represents
|
||
a reference to some piece of state shared with multiple parts of the application, but you are not
|
||
allowed to write to it. Every persistence strategy discussed above works with ``SharedReader``,
|
||
however if you try to mutate the state you will get a compiler error:
|
||
|
||
```swift
|
||
@SharedReader(.appStorage("isOn")) var isOn = false
|
||
isOn = true // 🛑
|
||
```
|
||
|
||
It is also possible to make custom persistence strategies that only have the notion of loading and
|
||
subscribing, but cannot write. To do this you will conform only to the ``PersistenceReaderKey``
|
||
protocol instead of the full ``PersistenceKey`` protocol.
|
||
|
||
For example, you could create a `.remoteConfig` strategy that loads (and subscribes to) a remote
|
||
configuration file held on your server so that it is kept automatically in sync:
|
||
|
||
```swift
|
||
@SharedReader(.remoteConfig) var remoteConfig
|
||
```
|
||
|
||
## Type-safe keys
|
||
|
||
Due to the nature of persisting data to external systems, you lose some type safety when shuffling
|
||
data from your app to the persistence storage and back. For example, if you are using the
|
||
``PersistenceReaderKey/fileStorage(_:decoder:encoder:)`` strategy to save an array of users to disk you might do so
|
||
like this:
|
||
|
||
```swift
|
||
extension URL {
|
||
static let users = URL(/* ... */))
|
||
}
|
||
|
||
@Shared(.fileStorage(.users)) var users: [User] = []
|
||
```
|
||
|
||
And say you have used this file storage users in multiple places throughout your application.
|
||
|
||
But then, someday in the future you may decide to refactor this data to be an identified array
|
||
instead of a plain array:
|
||
|
||
```swift
|
||
// Somewhere else in the application
|
||
@Shared(.fileStorage(.users)) var users: IdentifiedArrayOf<User> = []
|
||
```
|
||
|
||
But if you forget to convert _all_ shared user arrays to the new identified array your application
|
||
will still compile, but it will be broken. The two types of storage will not share state.
|
||
|
||
To add some type-safety and reusability to this process you can extend the ``PersistenceReaderKey``
|
||
protocol to add a static variable for describing the details of your persistence:
|
||
|
||
```swift
|
||
extension PersistenceReaderKey where Self == FileStorageKey<IdentifiedArrayOf<User>> {
|
||
static var users: Self {
|
||
fileStorage(.users)
|
||
}
|
||
}
|
||
```
|
||
|
||
Then when using [`@Shared`](<doc:Shared>) you can specify this key directly without `.fileStorage`:
|
||
|
||
```swift
|
||
@Shared(.users) var users: IdentifiedArrayOf<User> = []
|
||
```
|
||
|
||
And now that the type is baked into the key you cannot accidentally use the wrong type because you
|
||
will get an immediate compiler error:
|
||
|
||
```swift
|
||
@Shared(.users) var users = [User]()
|
||
```
|
||
|
||
> 🛑 Error: Cannot convert value of type '[User]' to expected argument type 'IdentifiedArrayOf<User>'
|
||
|
||
This technique works for all types of persistence strategies. For example, a type-safe `.inMemory`
|
||
key can be constructed like so:
|
||
|
||
```swift
|
||
extension PersistenceReaderKey where Self == InMemoryKey<IdentifiedArrayOf<User>> {
|
||
static var users: Self {
|
||
inMemory("users")
|
||
}
|
||
}
|
||
```
|
||
|
||
And a type-safe `.appStorage` key can be constructed like so:
|
||
|
||
```swift
|
||
extension PersistenceReaderKey where Self == AppStorageKey<Int> {
|
||
static var count: Self {
|
||
appStorage("count")
|
||
}
|
||
}
|
||
```
|
||
|
||
And this technique also works on [custom persistence](<doc:SharingState#Custom-persistence>)
|
||
strategies.
|
||
|
||
Further, you can use the ``PersistenceKeyDefault`` type to also provide a default that is used
|
||
with the persistence strategy. For example, to use a default value of `[]` with the `.users`
|
||
persistence strategy described above, we can do the following:
|
||
|
||
```swift
|
||
extension PersistenceReaderKey
|
||
where Self == PersistenceKeyDefault<FileStorageKey<IdentifiedArrayOf<User>>>
|
||
{
|
||
static var users: Self {
|
||
PersistenceKeyDefault(.fileStorage(.users), [])
|
||
}
|
||
}
|
||
```
|
||
|
||
And now anytime you reference the shared users state you can leave off the default value, and
|
||
you can even leave off the type annotation:
|
||
|
||
```swift
|
||
@Shared(.users) var users
|
||
```
|
||
|
||
## Shared state in pre-observation apps
|
||
|
||
It is possible to use [`@Shared`](<doc:Shared>) in features that have not yet been updated with
|
||
the observation tools released in 1.7, such as the ``ObservableState()`` macro. In the reducer
|
||
you can use `@Shared` regardless of your use of the observation tools.
|
||
|
||
However, if you are deploying to iOS 16 or earlier, then you must use `WithPerceptionTracking`
|
||
in your views if you are accessing shared state. For example, the following view:
|
||
|
||
```swift
|
||
struct FeatureView: View {
|
||
let store: StoreOf<Feature>
|
||
|
||
var body: some View {
|
||
Form {
|
||
Text(store.sharedCount.description)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
…will not update properly when `sharedCount` changes. This view will even generate a runtime warning
|
||
letting you know something is wrong:
|
||
|
||
> 🟣 Runtime Warning: Perceptible state was accessed but is not being tracked. Track changes to
|
||
> state by wrapping your view in a 'WithPerceptionTracking' view.
|
||
|
||
The fix is to wrap the body of the view in `WithPerceptionTracking`:
|
||
|
||
```swift
|
||
struct FeatureView: View {
|
||
let store: StoreOf<Feature>
|
||
|
||
var body: some View {
|
||
WithPerceptionTracking {
|
||
Form {
|
||
Text(store.sharedCount.description)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## Concurrent mutations to shared state
|
||
|
||
While the [`@Shared`](<doc:Shared>) property wrapper makes it possible to treat shared state
|
||
_mostly_ like regular state, you do have to perform some extra steps to mutate shared state from
|
||
an asynchronous context. This is because shared state is technically a reference deep down, even
|
||
though we take extra steps to make it appear value-like. And this means it's possible to mutate the
|
||
same piece of shared state from multiple threads, and hence race conditions are possible.
|
||
|
||
To mutate a piece of shared state in an isolated fashion, use the ``Shared/withLock(_:)`` method
|
||
defined on the `@Shared` projected value:
|
||
|
||
```swift
|
||
await state.$count.withLock { $0 += 1 }
|
||
```
|
||
|
||
That locks the entire unit of work of reading the current count, incrementing it, and storing it
|
||
back in the reference.
|
||
|
||
Technically it is still possible to write code that has race conditions, such as this silly example:
|
||
|
||
```swift
|
||
let currentCount = state.count
|
||
await state.$count.withLock { $0 = currentCount + 1 }
|
||
```
|
||
|
||
But there is no way to 100% prevent race conditions in code. Even actors are susceptible to
|
||
problems due to re-entrancy. To avoid problems like the above we recommend wrapping as many
|
||
mutations of the shared state as possible in a single ``Shared/withLock(_:)``. That will make
|
||
sure that the full unit of work is guarded by a lock.
|
||
|
||
> Note: You may encounter a deprecation warning when simply _accessing_ shared state from an
|
||
> asynchronous context when you chain into a subscript:
|
||
>
|
||
> ```swift
|
||
> return .run { _ in
|
||
> @Shared(.posts) var posts
|
||
> let post = posts[id: id] // ⚠️ Setter is unavailable from asynchronous contexts
|
||
> // ...
|
||
> }
|
||
> ```
|
||
>
|
||
> This is a [known issue](https://github.com/apple/swift/issues/74203) in the Swift compiler, but
|
||
> can be worked around using ``Shared/withLock(_:)`` to access the underlying value instead:
|
||
>
|
||
> ```swift
|
||
> return .run { _ in
|
||
> @Shared(.posts) var posts
|
||
> let post = await $posts.withLock { $0[id: id] }
|
||
> // ...
|
||
> }
|
||
> ```
|
||
|
||
## Gotchas of @Shared
|
||
|
||
There are a few gotchas to be aware of when using shared state in the Composable Architecture.
|
||
|
||
#### Hashability
|
||
|
||
Because the `@Shared` type is equatable based on its wrapped value, and because the value is held
|
||
in a reference and can change over time, it cannot be hashable. This also means that types
|
||
containing `@Shared` properties should not compute their hashes from shared values.
|
||
|
||
#### Codability
|
||
|
||
The `@Shared` type is not conditionally encodable or decodable because the source of truth of the
|
||
wrapped value is rarely local: it might be derived from some other shared value, or it might rely on
|
||
loading the value from a backing persistence strategy.
|
||
|
||
When introducing shared state to a data type that is encodable or decodable, you must provide your
|
||
own implementations of `encode(to:)` and `init(from:)` that do the appropriate thing.
|
||
|
||
For example, if the data type is sharing state with a persistence strategy, you can decode by
|
||
delegating to the memberwise initializer that implicitly loads the shared value from the property
|
||
wrapper's persistence strategy, or you can explicitly initialize a shared value via
|
||
``Shared/init(wrappedValue:_:fileID:line:)-512rh``. And for encoding you can often skip encoding
|
||
the shared value:
|
||
|
||
```swift
|
||
struct AppState {
|
||
@Shared(.appStorage("launchCount")) var launchCount = 0
|
||
var todos: [String] = []
|
||
}
|
||
|
||
extension AppState: Codable {
|
||
enum CodingKeys: String, CodingKey { case todos }
|
||
|
||
init(from decoder: any Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
|
||
// Use the property wrapper default via the memberwise initializer:
|
||
try self.init(
|
||
todos: container.decode([String].self, forKey: .todos)
|
||
)
|
||
|
||
// Or initialize the shared storage manually:
|
||
self._launchCount = Shared(wrappedValue: 0, .appStorage("launchCount"))
|
||
self.todos = try container.decode([String].self, forKey: .todos)
|
||
}
|
||
|
||
func encode(to encoder: any Encoder) throws {
|
||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||
try container.encode(self.todos, forKey: .todos)
|
||
// Skip encoding the launch count.
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Previews
|
||
|
||
When a preview is run in an app target, the entry point is also created. This means if your entry
|
||
point looks something like this:
|
||
|
||
```swift
|
||
@main
|
||
struct MainApp: App {
|
||
let store = Store(…)
|
||
|
||
var body: some Scene {
|
||
…
|
||
}
|
||
}
|
||
```
|
||
|
||
…then a store will be created each time you run your preview. This can be problematic with `@Shared`
|
||
and persistence strategies because the first access of a `@Shared` property will use the default
|
||
value provided, and that will cause `@Shared`'s created later to ignore the default. That will mean
|
||
you cannot override shared state in previews.
|
||
|
||
The fix is to delay creation of the store until the entry point's `body` is executed. Further, it
|
||
can be a good idea to also not run the `body` when in tests because that can also interfere with
|
||
tests (as documented in <doc:Testing#Testing-gotchas>). Here is one way this can be accomplished:
|
||
|
||
```swift
|
||
import ComposableArchitecture
|
||
import SwiftUI
|
||
|
||
@main
|
||
struct MainApp: App {
|
||
@MainActor
|
||
static let store = Store(…)
|
||
|
||
var body: some Scene {
|
||
WindowGroup {
|
||
if isTesting {
|
||
// NB: Don't run application in tests to avoid interference
|
||
// between the app and the test.
|
||
EmptyView()
|
||
} else {
|
||
AppView(store: Self.store)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Alternatively you can take an extra step to override shared state in your previews:
|
||
|
||
```swift
|
||
#Preview {
|
||
@Shared(.appStorage("isOn")) var isOn = true
|
||
isOn = true
|
||
}
|
||
```
|
||
|
||
The second assignment of `isOn` will guarantee that it holds a value of `true`.
|
||
|
||
## Topics
|
||
|
||
### Essentials
|
||
|
||
- ``Shared``
|
||
|
||
### Persistence strategies
|
||
|
||
- ``AppStorageKey``
|
||
- ``FileStorageKey``
|
||
- ``InMemoryKey``
|
||
|
||
### Custom persistence
|
||
|
||
- ``PersistenceKey``
|
||
|
||
### Read-only persistence
|
||
|
||
- ``SharedReader``
|
||
- ``PersistenceReaderKey``
|
||
|
||
### Default values
|
||
|
||
- ``PersistenceKeyDefault``
|