EP 272 · Shared State · Mar 25, 2024 ·Members

Video #272: Shared State: Testing, Part 2

smart_display

Loading stream…

Video #272: Shared State: Testing, Part 2

Episode: Video #272 Date: Mar 25, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep272-shared-state-testing-part-2

Episode thumbnail

Description

We will employ @Shared’s new testing capabilities in a complex scenario: a sign up flow. We will see how a deeply nested integration of features all sharing the same state can be tested simply, and we will see how we can leverage the same tricks employed by the test store to add debug tools to reducers using shared state.

Video

Cloudflare Stream video ID: 01b1fd7d1d1327e53d635c4bbc5ac104 Local file: video_272_shared-state-testing-part-2.mp4 *(download with --video 272)*

Transcript

0:05

So, it may not seem like it, but what we have accomplished is actually quite amazing. We are writing tests for features that contain a reference type as if they are just regular features built on value types. We are exhaustively testing every aspect of the feature, while at the same time being able to share a piece of state with multiple features. This means we have all of the ergonomics and powers of reference types, with seemingly none of the down sides. Brandon

0:27

But so far the tests we have been dealing with have been quite simple. Let’s see a more real world and advanced example. In fact, the sign up flow we built the previous episode would be a great testing ground for this. That was dealing with state shared amongst many features, and if we can simple tests for that, then we would know we have achieved something pretty amazing.

0:48

Let’s take a look. Testing the sign up flow

0:51

Let’s start by creating a new test file.

0:56

And paste some scaffolding for a new test: import ComposableArchitecture import XCTest @testable import SwiftUICaseStudies final class SignUpFlowTests: XCTestCase { @MainActor func testBasics() { } }

0:59

To construct a TestStore focused in on the SignUpFeature we are going to have to decide how to provide the shared state for the sign up data: @MainActor func testBasics() { let store = TestStore( initialState: SignUpFeature.State( signUpData: <#Shared<SignUpData>#> ) ) { SignUpFeature() } }

1:24

We have two choices for this. We can either create the shared state inline and pass it directly to the feature: let store = TestStore( initialState: SignUpFeature.State( signUpData: Shared(SignUpData()) ) ) { SignUpFeature() }

1:33

Or we can create the shared state up front, and then pass it to the feature’s state: let signUpData = Shared(SignUpData()) let store = TestStore( initialState: SignUpFeature.State( signUpData: signUpData ) ) { SignUpFeature() }

1:41

Either style can be appropriate depending on the kinds of tests you are writing. Sometimes you need to reference the shared state a bunch during the test, and so it can be appropriate to break it out into a separate property like we have done here. And other times that doesn’t really happen, and so then it’s fine to just inline it directly into the state initializer. For now we will just keep things as they are.

1:59

We do have an error right now: Initializer ‘init(initialState:reducer:withDependencies:file:line:)’ requires that ‘SignUpFeature.State’ conform to ‘Equatable’

2:06

…letting us know that State must be equatable. We didn’t need that while working on the feature thanks to Swift’s observation tools, because now observation is done by tracking which properties are accessed rather than de-duping state changes with equality.

2:18

So, let’s mark everything Equatable , starting with SignUpFeature.State : @Reducer struct SignUpFeature { @ObservableState struct State: Equatable { … } … }

2:20

This requires that Path.State be Equatable too.

2:44

Ideally we would extend Path.State to make it equatable: extension SignUpFeature.Path.State: Equatable {}

2:57

But unfortunately due to a Swift compiler bug this does not compile: Circular reference resolving attached macro ‘Reducer’

3:04

Luckily this has been fixed, but it did not make it into Swift 5.10, so I guess we’ll have to wait until Swift 6 for a fix. In the meantime, we can pass an argument to the @Reducer macro when applied to the Path enum to specify any conformances: @Reducer(state: .equatable) enum Path { … }

3:24

But, with that done, in order for Path.State to be equatable we have to further make all the states that it holds onto equatable. That means the BasicsFeature : @Reducer struct BasicsFeature { @ObservableState struct State: Equatable { … } … } …and the PersonalInfoFeature : @Reducer struct PersonalInfoFeature { @ObservableState struct State: Equatable { … } … } …and the TopicsFeature : @Reducer struct TopicsFeature { @ObservableState struct State: Equatable { … } … } …and the SummaryFeature : @Reducer struct SummaryFeature { @ObservableState struct State: Equatable { … } … } And finally the SummaryFeature ’s Destination reducer also needs equatable state: @Reducer(state: .equatable) enum Destination { case personalInfo(PersonalInfoFeature) case topics(TopicsFeature) }

4:08

Now everything is compiling, and the warning in the test has gone away, which means we are ready to start making some assertions. But what do we want to assert?

4:15

There’s a lot of user flows in this feature that we could assert on, but let’s just concentrate on specific one. Let’s start off in a state where we are already on the topics step, and then we will exercise that validation logic for when trying to go to the next step without selecting a topic. Then we will add a topic, get to the final summary screen, and further exercise the flow of bringing up the edit sheet for the topics to make a change to the selected topics.

4:53

So, first we need to tweak the initialState of the test store so that we start in a state where we are drilled down to the topics feature: let signUpData = Shared(SignUpData()) let store = TestStore( initialState: SignUpFeature.State( path: StackState([ .basics(BasicsFeature.State(signUpData: signUpData)), .personalInfo( PersonalInfoFeature.State(signUpData: signUpData) ), .topics(TopicsFeature.State(signUpData: signUpData)) ]), signUpData: signUpData ) ) { SignUpFeature() }

5:37

Note that we can simply provide the same piece of shared state to each feature.

5:45

Now we can send an action in the SignUpFeature domain: await store.send(<#SignUpFeature.Action#>)

6:00

But really we want to send an action all the way in the topics feature. We want to tap the “Next” button so that we can see what happens. So that means we need to send a .path action: await store.send(.path(<#StackAction#>))

6:14

There are a couple of types of stack actions we can send, such as push and popping, but the one we want is the element action which allows us to send an action within an element in the stack: await store.send( .path( .element( id: <#StackElementID#>, action: <#SignUpFeature.Path.Action#> ) ) )

6:32

To do so we need the ID of the element in the stack. During tests stack element IDs are just auto-incrementing integers, and since we have 3 features on the stack this means the IDs of 0, 1 and 2 have been used so far. And 2 corresponds to the topics feature: await store.send( .path( .element( id: 2, action: <#SignUpFeature.Path.Action#> ) ) )

6:48

Now we can specify which feature at id: 2 we want to send an action for, which is the .topics feature in our situation: await store.send( .path( .element( id: 2, action: .topics(<#TopicsFeature.Action#>) ) ) )

7:03

And finally we can emulate the user tapping on the “Next” button in the topics screen: await store.send( .path( .element( id: 2, action: .topics(.nextButtonTapped) ) ) )

7:11

It may seem like a lot, but this is also pretty powerful. We have the integration of multiple features into a navigation stack, and we are able to emulate various user actions happening in element of the stack.

7:42

However, sending deeply nested actions like this really only happens in tests, where we are exercising how many features fit together at once. Over in our actual application code we hardly ever send nested actions, and honestly we would highly recommend against doing that. It is far better to use the navigation tools of the library to scope down stores and pass them to child views than send nested actions.

8:28

And so for this reason there is an alternate way to send deeply nested actions in test stores, and it’s something we released very recently and was contributed by community member George Scott , so if you haven’t see this yet don’t worry. It’s brand new.

8:44

We can use key path syntax to describe the action. In this case we can start by describing that we want to send a .path action like so: await store.send( .path( .element( id: 2, action: .topics(.nextButtonTapped) ) ) ) await store.send(\.path)

9:15

Then, to specify an element action at a particular ID we can simply use subscript syntax: await store.send( .path( .element( id: 2, action: .topics(.nextButtonTapped) ) ) ) await store.send(\.path[id: 2]) This syntax mimics the equivalent syntax for subscripting in the path state too: // store.state.path[id: 2]

9:23

Next we can use auto-complete to further specify that we want to send a .topics action: await store.send( .path( .element( id: 2, action: .topics(.nextButtonTapped) ) ) ) await store.send(\.path[id: 2].topics)

9:31

And finally we can use auto-complete again to send the nextButtonTapped action: await store.send( .path( .element( id: 2, action: .topics(.nextButtonTapped) ) ) ) await store.send(\.path[id: 2].topics.nextButtonTapped)

9:49

These lines are equivalent, but clearly one is shorter. And because the nextButtonTapped doesn’t take any data, we can just leave it like this. But if you want to send an action that has data, you need to provide a second argument for the data. For example, if we had stopped appending key paths at .topics , then we would need to provide the payload that is to be embedded in the topics case: await store.send(\.path[id: 2].topics, .nextButtonTapped)

10:37

OK, now that we are sending an action we must assert on how state changes after. Since we haven’t selected any topics in this step I expect an alert to be shown letting the user know that at least one topic must be selected before moving on.

11:03

So, to do this we open a trailing closure on store.send : await store.send(\.path[id: 2].topics.nextButtonTapped) { }

11:22

We expect state to change deep inside the feature’s state. First it’s in the path , and in particular the element with ID 2: $0.path[id: 2]

11:34

And further it’s in the .topics case of that element, which can be done using a special subscript on path : $0.path[id: 2, case: \.topics]

11:48

And then we can further chain into that state to mutate the alert: $0.path[id: 2, case: \.topics]?.alert = AlertState { TextState("Please choose at least one topic.") }

12:08

Let’s run the test to see that it passes.

12:13

So, this is great, but we recently made more improvements to the library that allow us to shorten this even more. We can actually dot-chain into the path directly to pick out the topics case: $0.path[id: 2]?.topics

12:40

And then further chain into the alert state: $0.path[id: 2]?.topics?.alert = AlertState { TextState("Please choose at least one topic.") }

12:46

This works for modifying the data inside any enum that has been marked with the @CasePathable macro. It’s a very powerful tool.

12:59

And this proves that we will show the user an alert and not let them go to the next step unless they select a topic.

13:33

Next we want to emulate selecting a topic in the UI so that we will be allowed to go to the summary screen, so let’s do it. We will send another action that emulates the user selecting a topic, which means we need to again send an action in the topics domain: await store.send(\.path[id: 2].topics.<#⎋#>)

13:51

This time however we are going to send a binding action to emulate interacting with the Toggle that selects a topic: await store.send(\.path[id: 2].topics.binding.<#⎋#>)

14:06

And then we can further chain onto this the field in state we want to update: await store.send(\.path[id: 2].topics.binding.signUpData)

14:09

And then we just have to provide the SignUpData that has the topic added: await store.send( \.path[id: 2].topics.binding.signUpData, SignUpData(topics: [.testing]) )

14:30

Next we have to provide a trailing closure that asserts on how the state changes, but before even doing that let’s just run the test.

14:52

We get a test failure showing us exactly what went wrong: A state change does not match expectation: … SignUpFeature.State( _path: [ #0: .basics( BasicsFeature.State( _signUpData: SignUpData( email: "", firstName: "", lastName: "", password: "", passwordConfirmation: "", phoneNumber: "", topics: Set([ + .testing ]) ) ) ), #1: .personalInfo( PersonalInfoFeature.State( _signUpData: SignUpData( email: "", firstName: "", lastName: "", password: "", passwordConfirmation: "", phoneNumber: "", topics: Set([ + .testing ]) ) ) ), #2: .topics( TopicsFeature.State( _alert: AlertState(title: "Please choose at least one topic."), _signUpData: SignUpData( email: "", firstName: "", lastName: "", password: "", passwordConfirmation: "", phoneNumber: "", topics: Set([ + .testing ]) ) ) ) ], _signUpData: SignUpData( email: "", firstName: "", lastName: "", password: "", passwordConfirmation: "", phoneNumber: "", topics: Set([ + .testing ]) ) ) (Expected: −, Actual: +) This shows that we did not assert on how topics mutated. And further it is even showing us all the different places that are holding onto this piece of shared state.

15:02

Let’s now get this test passing by providing a trailing closure and mutating the path at the element with id: 2 , in the .topics case, and then further mutating the shared signUpData ’s topics so that it contains .testing : await store.send( \.path[id: 2].topics.binding.signUpData, SignUpData(topics: [.composableArchitecture]) ) { $0.path[id: 2]?.topics?.signUpData.topics = [ .composableArchitecture ] }

15:16

And just like that the test is passing.

15:18

This is pretty incredible. We have a reference type in all of these features’ states and yet the test store is able to still keep us in check and force us to exhaustively prove how state changes. It’s easy to forget how amazing this is since it just works so seamlessly, but we really need to close our eyes tightly and think back to what it was like to stick a reference type inside a value type. It instantly destroys our ability to make assertions on that state. We can only assert on each field individually and must always remember to update those assertions when we added new fields in the future.

16:00

A funny thing about this code is that we decided to mutate the signUpData that is deeply nested inside the path. But this state is shared with all the features, including the root SignUpFeature , and so we can actually mutate any of those states and it should still pass. For example, we can simplify to this: // $0.path[id: 2]?.topics]?.signUpData.topics = [ // .composableArchitecture // ] $0.signUpData.topics = [.testing]

16:26

…and the test still passes, but this is a lot simpler.

16:41

Now that we have selected a topic we can now try going to the next step: await store.send(\.path[id: 2].topics.nextButtonTapped)

17:04

And what do we expect to happen?

17:09

Well, if you remember we implemented the TopicsFeature by having it send a delegate action once the validation of the topics pass: case .nextButtonTapped: if state.signUpData.topics.isEmpty { state.alert = AlertState { TextState("Please choose at least one topic.") } } else { return .send(.delegate(.stepFinished)) } return .none

17:22

This was our way of communicating to the parent so that it could push on the next feature in the flow. So, we have to assert on this behavior by asserting that the store received a delegate action in the topics feature: await store.receive(\.path[id: 2].topics.delegate.<#⎋#>)

17:50

However, autocomplete here is not showing us anything useful. The only reason that we are even allowed to be using key path syntax on these enums is because the @Reducer macro automatically applies the @CasePathable macro to every feature’s action. And its case paths that give us key path magic for enum cases.

18:20

However, the Delegate enum does not automatically have the @CasePathable macro applied, and so we have to do it manually: @CasePathable enum Delegate { case stepFinished } It would be nice if the @Reducer macro was able to recursively apply the @CasePathable macro to all enums nested inside, but unfortunately this is not possible with Swift macros today.

18:38

Now we get much better autocomplete, and we see that we can assert that we receive the stepFinished delegate action: await store.receive( \.path[id: 2].topics.delegate.stepFinished )

18:46

Then we have to provide a trailing closure to assert on how state changes. In particular, we expect a new element to be added to the path with ID 3, and it should be the summary feature: await store.receive( \.path[id: 2].topics.delegate.stepFinished ) { $0.path[id: 3] = .summary( SummaryFeature.State(signUpData: signUpData) ) }

19:04

And just like that we have a passing test that proves that once the topics screen validates the topics data it will cause the summary feature to be pushed onto the stack.

19:18

Next let’s emulate the user tapping the “Edit” button on the personal info section, which should cause the destination state to be populated, which in turns means a sheet pops up on the screen: await store.send( \.path[id: 3].summary.editPersonalInfoButtonTapped ) { $0.path[id: 3]?.summary?.destination = .personalInfo( PersonalInfoFeature.State(signUpData: signUpData) ) }

20:08

This test is passing.

20:10

Next we will emulate the user editing their first name by sending a binding action in the destination of the summary feature on the stack: await store.send( \.path[id: 3].summary.destination.personalInfo.binding.signUpData, SignUpData( firstName: "Blob", topics: [.testing] ) ) { $0.signUpData.firstName = "Blob" }

21:28

This passes.

21:53

And finally we can emulate the user dismissing the sheet which causes the destination state to nil out: await store.send(\.path[id: 3].summary.destination.dismiss) { $0.path[id: 3]?.summary?.destination = nil }

22:20

And now this entire test suite is passing. Debugging shared state

22:23

We have accomplished the seemingly impossible. We have multiple features all sharing the same reference of state, and amazingly we are getting exhaustive testing on everything happening inside the features. This provides incredible guard rails for us so that we can make sure that our features work exactly as we expect.

22:40

This is only possible thanks to the closed system that is the Composable Architecture, in particular the Store . It gets to monitor everything happening in the system, and that gives us some incredible super powers for making things work the way we want them to. Stephen

22:54

There is one other tool in the Composable Architecture that is closely related to testing, and it is also in a troubled spot right now. It’s the _printChanges reducer operator that lets you see every action that comes into the system, and it prints out a nicely formatted diff of the state comparing the before and after the action was sent.

23:12

It can be incredibly useful for debugging applications, but currently it is completely broken. Let’s see what it takes to fix it.

23:22

Let’s start by seeing the problem very clearly. We can update the preview of the shared state demo by applying the _printChanges reducer operator: #Preview { SharedStateView( store: Store( initialState: SharedState.State( stats: Shared(Stats()) ) ) { SharedState() ._printChanges() } ) }

23:33

If now run the preview, and increment, we get a very unhelpful message: received action: SharedState.Action.counter(.incrementButtonTapped) (No state changes)

23:41

This is happening for the reason it was happening over in the TestStore . The _printChanges reducer is currently not capable of seeing the before and after of the shared state in order to print a nicely formatted message.

23:51

We can jump over to the _PrintChangesReducer to see how it works: if let printer = self.printer { let oldState = state let effects = self.base.reduce( into: &state, action: action ) return effects.merge( with: .publisher { [state, queue = printer.queue] in Deferred<Empty<Action, Never>> { queue.async { printer.printChange( receivedAction: action, oldState: state, newState: newState ) } return Empty() } } ) }

23:57

We capture the old state, then we run the base reducer, and then in an effect we print the change with the old state and new state.

24:08

But of course, when state contains references it doesn’t matter if we capture the old and new state. Each of those values still contain the same reference.

24:14

The way we allowed the TestStore to see the before and after of shared references is by installing a change tracker when running the reducer in the store: let sharedStateDidChange = LockIsolated(false) let task = SharedLocals.$changeTracker.withValue({ sharedStateDidChange.setValue(true) }) { self.store.send( .init(origin: .send(action), file: file, line: line), originatingFrom: nil ) }

24:23

This tells all shared references to take a snapshot of their current state when the first mutation is applied.

24:27

Sounds like we need to do something in the _printChanges reducer. If we run the base reducer with a change tracker then it should snapshot state, and maybe that means we can then properly print out the difference between the before and after: let effects = SharedLocals.$changeTracker.withValue({}) { self.base.reduce(into: &state, action: action) }

25:00

But to use this internal symbol in this reducer we will need to drop the inlining: // @inlinable public func reduce( … )

25:11

We don’t necessarily even need to put in a meaningful change tracker. We just need something so that shared references start snapshotting their state.

25:18

But installing a change tracker and getting shared references to snapshot their state is only one step of the process. We also need to use the isAsserting task local in order to communicate to all shared references that they need to use their snapshots when comparing two references for equality or trying to dump the contents of a reference.

25:34

And we want to do this at precisely the moment we are printing the changes from the old state to the new state: printer.printChange( receivedAction: action, oldState: state, newState: newState )

25:40

So, let’s wrap this in withValue to alter the task local: SharedLocals.$isAsserting.withValue(true) { printer.printChange( receivedAction: action, oldState: state, newState: newState ) }

25:51

Hopefully that is all that would be required. It’s a two step process: first, when running the base reducer we tell all shared references to capture their snapshots by installing a change tracker, and then when printing the difference between the old and new state we tell shared references to use their snapshot in order to possibly differentiate two identical references.

26:11

So, let’s run the preview again and tap the increment button. We see the following printed to the console: received action: SharedState.Action.counter(.incrementButtonTapped) SharedState.State( _currentTab: .counter, _counter: CounterTab.State( _alert: nil, _stats: Stats( - count: 0, + count: 1, - maxCount: 0, + maxCount: 1, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 1 ) ), _profile: ProfileTab.State( _stats: Stats( - count: 0, + count: 1, - maxCount: 0, + maxCount: 1, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 1 ) ), _stats: Stats( - count: 0, + count: 1, - maxCount: 0, + maxCount: 1, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 1 ) )

26:25

That seems about right to me. So maybe that’s all it takes.

26:28

Well, let’s increment one more time just to be sure: received action: SharedState.Action.counter(.incrementButtonTapped) SharedState.State( _currentTab: .counter, _counter: CounterTab.State( _alert: nil, _stats: Stats( - count: 0, + count: 2, - maxCount: 0, + maxCount: 2, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 2 ) ), _profile: ProfileTab.State( _stats: Stats( - count: 0, + count: 2, - maxCount: 0, + maxCount: 2, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 2 ) ), _stats: Stats( - count: 0, + count: 2, - maxCount: 0, + maxCount: 2, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 2 ) )

26:31

Huh, OK. That does not look right. It seems to think the “before” count is 0, even though it should be 1.

26:41

This is happening because although we are properly snapshotting all shared references before their first mutation, we are never clearing the snapshots. So when a second action is sent, and we try printing the diff between an old snapshot of the shared reference when the current value, we are actually comparing the first snapshot. Which is when all the state was 0.

26:59

We need to clear all snapshots after the diff has been printed so the next action we start with a clear slate and capture a snapshot all over again. We could possibly accomplish this with yet another task local to communicate to all shared references in the app, but also we can leverage the change tracker.

27:18

What if we made the change tracker into a class so that it has a lifetime of its own, and when it is deallocated we will clear the snapshot. That will give us the ability to control how long we want the snapshot to be captured, and that should fix the problems we are seeing.

27:30

Such a class could look like this: class ChangeTracker { var onDeinit: () -> Void = {} deinit { self.onDeinit() } } It’s just a simple class wrapping a void-to-void closure that is invoked when the class is deallocated.

27:52

But we further need to hold onto some state that determines whether or not something was changed, because that’s what we use in the test store to control certain assertion behavior: class ChangeTracker { var onDeinit: () -> Void = {} var didChange = false deinit { self.onDeinit() } }

28:05

Then we can update the SharedLocals to hold onto an optional of this class: @TaskLocal static var changeTracker: ChangeTracker?

28:12

And we can update the set of the wrappedValue by installing the onDeinit callback when the snapshot is captured, and by flipping the didChange when a mutation is made: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { if SharedLocals.isTracking, self.snapshot == nil { self.snapshot = self.currentValue SharedLocals.changeTracker?.onDeinit = { self.snapshot = nil } } self.currentValue = newValue SharedLocals.changeTracker?.didChange = true } }

28:33

We have a few places we need to update now. In the TestStore when running the reducer we will wrap that work in a change tracker: let changeTracker = ChangeTracker() let task = SharedLocals.$changeTracker.withValue(changeTracker) { self.store.send( .init(origin: .send(action), file: file, line: line), originatingFrom: nil ) }

28:46

…and we will force skip the check for an unnecessary modify if the change tracker reports a change: skipUnnecessaryModifyFailure: changeTracker.didChange,

28:56

And then further we can do something similar in the _printChanges reducer: let changeTracker = ChangeTracker() let effects = SharedLocals.$changeTracker.withValue(changeTracker) { self.base.reduce(into: &state, action: action) }

29:08

But now we need to keep this changeTracker alive until the diff of state is printed. That way all shared references will use their snapshots when comparing for equality and dumping their contents.

29:16

To do that we will just capture the object right after the changes are printed: queue.async { SharedLocals.$isAsserting.withValue(true) { printer.printChange( receivedAction: action, oldState: state, newState: newState ) } _ = changeTracker }

29:27

And that will keep the object alive for just long enough.

29:29

Now when we run the preview and increment a few times we see the correct output: received action: SharedState.Action.counter(.incrementButtonTapped) SharedState.State( _currentTab: .counter, _counter: CounterTab.State( _alert: nil, _stats: Stats( - count: 0, + count: 1, - maxCount: 0, + maxCount: 1, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 1 ) ), _profile: ProfileTab.State( _stats: Stats( - count: 0, + count: 1, - maxCount: 0, + maxCount: 1, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 1 ) ), _stats: Stats( - count: 0, + count: 1, - maxCount: 0, + maxCount: 1, minCount: 0, - numberOfCounts: 0 + numberOfCounts: 1 ) ) received action: SharedState.Action.counter(.incrementButtonTapped) SharedState.State( _currentTab: .counter, _counter: CounterTab.State( _alert: nil, _stats: Stats( - count: 1, + count: 2, - maxCount: 1, + maxCount: 2, minCount: 0, - numberOfCounts: 1 + numberOfCounts: 2 ) ), _profile: ProfileTab.State( _stats: Stats( - count: 1, + count: 2, - maxCount: 1, + maxCount: 2, minCount: 0, - numberOfCounts: 1 + numberOfCounts: 2 ) ), _stats: Stats( - count: 1, + count: 2, - maxCount: 1, + maxCount: 2, minCount: 0, - numberOfCounts: 1 + numberOfCounts: 2 ) )

29:50

Pretty incredible. Not only can we make sure that our testing tools continue to work correctly when references types are put into state, but we can also make our debugging tools work correctly.

29:59

It’s worth mentioning that while we have gotten the basics of the _printChanges tool working, there is still a bit more work to be done to make it fully robust. But those details aren’t really that interesting, so we are going to leave it here. Next time: ubiquity and persistence

30:10

OK, this is all looking absolutely incredible. We have now seemingly fixed all of the problems we encountered with sharing state, and so we can now full heartedly and strongly recommend representing simple shared state in your applications as a reference type. Historically it would have been quite problematic to put a reference in a Composable Architecture feature, for two main reasons:

30:30

Reference types used to not play nicely with view invalidation, and so you could make changes to the data in the reference and that would not cause the view to re-render. That is no longer a concern thanks to Swift’s new observation tools. Brandon

30:42

And second, reference types are not easy to test and debug since you can’t capture the before and after values to compare. But we have now fixed that thanks to the Shared type and some new internal logic inside the TestStore .

30:57

Now everything we have accomplished so far is fantastic, and we could stop here and have a very compelling story for how to share state amongst features in the Composable Architecture. But we can make things even better. Stephen

31:10

Sometimes we want shared state to be very explicit and localized. This is how our case study is structured right now, and how we approached the complex sign up flow a few episodes back. If a feature wants a piece of shared state, it must use the @Shared property wrapper, and whoever creates that feature must pass along a piece of shared state.

31:28

But sometimes we want shared state to be ubiquitous throughout the application. Any feature should be able to reach out and grab the shared state immediately without it being passed around explicitly, and should be able to make changes to that shared state. Brandon

31:44

The prototypical example of this is settings. Settings is usually state that the entire app needs to be able to access, and that perhaps a few features also need to be able to write to. If we stopped with shared state as it is right now we would have to explicitly pass around Shared values to every feature that needs settings. And this is a viral situation. If some deep leaf feature needs access to settings, then every parent feature needs to also hold onto a Shared settings object just to pass it along, even if it doesn’t care about settings.

32:14

Let’s see what it takes share state across the entire application, instantly . Downloads Sample code 0272-shared-state-pt5 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .