Video #206: Reducer Protocol: Dependencies, Part 2
Episode: Video #206 Date: Sep 26, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep206-reducer-protocol-dependencies-part-2

Description
We now have a SwiftUI-inspired system for plucking dependencies out of thin air to provide them to reducers, but we can’t control them or separate interface from implementation. Once we do, we’ll have something far better than ever before.
Video
Cloudflare Stream video ID: 068166cbb79c4034dcbd54b49605c941 Local file: video_206_reducer-protocol-dependencies-part-2.mp4 *(download with --video 206)*
Transcript
— 0:30
There’s one last feature of our dependencies that we previously mentioned but haven’t yet implemented. And that’s the ability to override dependencies for a specific reducer. We previously alluded that this can be powerful for overriding dependencies for a child feature in order to alter the environment it operates in, such as onboarding experiences. This is definitely true, and can be incredibly powerful, but we can also see a simpler use case for this functionality in the code we’ve already written.
— 0:57
A moment ago when we were deleting all the initializing code of our reducers to no longer pass in dependencies, we removed the ability to pass special dependencies to the reducer for previews.
— 1:09
Let’s take a look at that. Overriding dependencies
— 1:13
Right now in the voice memos preview we initialize the VoiceMemos type with no arguments because it gets all of its dependencies implicitly from the global store: reducer: Reducer( VoiceMemos( // audioPlayer: .mock, // audioRecorder: .mock, // mainRunLoop: .main, // openSettings: {}, // temporaryDirectory: { // URL(fileURLWithPath: NSTemporaryDirectory()) // }, // uuid: { UUID() } ) ),
— 1:27
Previously we were able to provide dependencies to the feature so that we could control certain ones. For example, AVAudioPlayer and AVAudioRecorder do not work at all in previews. We can see this by running the preview right now and seeing that things are just really broken. It would be nice to be able to see certain behavior in this preview, such as what the progress bar looks like while playing a voice memo, or the animation of the recording interface appearing and disappearing.
— 1:51
This is why there are two mocks defined below that simulate the behavior of the player and recording without ever actually touching AVFoundation. The player just suspends for a few seconds if you hit the play endpoint, and the recorder starts a little internal timer to to simulate the currentTime updating. These mock dependencies allow us to get a better view into how our feature actually works without needing to run it in a simulator or on a device:
— 2:12
Unfortunately we do not currently have a way of using these dependencies in our previews. Earlier we theorized a reducer operator that would allow us to override a dependency for a particular reducer: VoiceMemos() .dependency(\.audioPlayer, .mock) .dependency(\.audioRecorder, .mock)
— 2:32
Let’s see what it takes to define such a reducer operator.
— 2:42
As always, we start with a method defined on the ReducerProtocol that will take some arguments and eventually return a whole new type that encapsulates the behavior. We can even take advantage of the fact that our reducer protocol now has associated types by returning a some type: extension ReducerProtocol { public func dependency<Value>( _ keyPath: WritableKeyPath<DependencyValues, Value>, _ value: Value ) -> some ReducerProtocol<State, Action> { } }
— 3:23
And now let’s try to define the type we will return.
— 3:27
We will take inspiration from the name for the corresponding type in SwiftUI, which we can see explicitly by printing the value. To see it quickly, let’s fire up a Swift REPL on the command line: $ swift repl Welcome to Apple Swift version 5.7 (swiftlang-5.7.0.123.8 clang-1400.0.29.50). Type :help for assistance. 1> import SwiftUI 2> print(type(of: Text("").environment(\.colorScheme, .dark))) ModifiedContent<Text, _EnvironmentKeyWritingModifier<ColorScheme>>
— 3:55
So the underlying type that powers the .environment view modifier is _EnvironmentKeyWritingModifier . We will call ours DependencyKeyWritingReducer : struct DependencyKeyWritingReducer: ReducerProtocol { }
— 4:11
It needs to be generic over the base reducer that we are operating on, as well as the type of value we are pulling from the dependencies storage, and we can even make the reducer private, since the dependency method returns a more opaque reducer. private struct DependencyKeyWritingReducer< Base: ReducerProtocol, Value >: ReducerProtocol { }
— 4:24
This reducer will need to hold onto the base reducer that we are transforming, as well as the key path that is capable of plucking out our dependency from the DependencyValues and the new dependency value we want to use: private struct DependencyKeyWritingReducer< Base: ReducerProtocol, Value >: ReducerProtocol { let base: Base let keyPath: WritableKeyPath<DependencyValues, Value> let value: Value }
— 4:41
And then we need to implement the reduce method, which will operate on the same state and actions as the base: func reduce( into state: inout Base.State, action: Base.Action ) -> Effect<Base.Action, Never> { }
— 4:53
Inside here we need to update the global DependencyValues with the new value, run the base reducer, and then restore the DependencyValues to its previous state: func reduce( into state: inout Base.State, action: Base.Action ) -> Effect<Base.Action, Never> { let previous = DependencyValues.current defer { DependencyValues.current = previous } DependencyValues.current[keyPath: self.keyPath] = self.value return self.base.reduce(into: &state, action: action) }
— 5:41
And finally we need to return this new type from the dependency method: extension ReducerProtocol { public func dependency<Value>( _ keyPath: WritableKeyPath<DependencyValues, Value>, _ value: Value ) -> some ReducerProtocol<State, Action> { DependencyKeyWritingReducer( base: self, keyPath: keyPath, value: value ) } }
— 5:51
With these changes the application now builds, but it is not yet correct. We can see this by trying to play one of the mocked voice memos in the preview and see that it shows an error alert.
— 6:12
This is happening because although we have changed the dependency for the duration that the reducer’s logic runs, that change does not persist long enough for the effect to see those changes. So, when we tap the play button and the effect executes, it is going to get the live dependency rather than the mock one.
— 6:26
And the reason this is happening is because we first mutate the dependencies, then run the reducer, and then immediately restore the dependencies to their previous state. However, we access all of these dependencies inside our effects, which execute in the closures passed to Effect.task , Effect.run or Effect.fireAndForget . Those closures are escaping, they have to be, and so by the time they execute the dependencies have been restored to their previous state, which means the effect never gets to see the overridden dependency.
— 7:02
To fix this we need to update our core 3 effect methods to grab the dependencies at the moment of creating the effect, so that we can temporarily restore those dependencies when the effect is actually run, and then reset back to the previous state when the effect finishes. For example, in Effect.task this can be done like so: let before = DependencyValues.current return Deferred<…> { let subject = PassthroughSubject<Output, Failure>() let task = Task(priority: priority) { @MainActor in defer { subject.send(completion: .finished) } do { try Task.checkCancellation() let after = DependencyValues.current DependencyValues.current = before let output = try await operation() DependencyValues.current = after try Task.checkCancellation() subject.send(output) } catch is CancellationError { … } … } }
— 7:41
…and similarly for Effect.run .
— 7:53
…and it looks like Effect.fireAndForget calls Effect.task under the hood, so we’re good to go.
— 8:00
It may seem a little strange that we have to do this copy-replace-and-restore dance, but this is actually how we think SwiftUI works under the hood too. Any view or view modifier that makes use of an escaping closure must copy-replace-and-restore the environment values so that the escaping closure gets access to the correct values. Incidentally, we also think this may have been the cause of many bugs in SwiftUI where it seems that environment values did not travel as deeply through a view hierarchy as you might expect, such as in sheets, popovers and navigation links.
— 8:28
With those changes we can now run the preview, “record” a new voice memo, and “playback” an existing voice memo.
— 8:45
Things are looking pretty good, but there are two big improvements we can make. First of all, let’s address the elephant in the room: currently our global store of dependencies is globally mutable: public struct DependencyValues { static var current = Self() … }
— 8:56
And considering dependencies are specifically supposed to be accessed from effects, which run on any thread in the cooperative thread pool, this can easily lead to some bad race conditions.
— 9:06
Swift would even warn us of all these problems if we turned concurrency warnings on the core library. Let’s do that real quick: .target( … swiftSettings: [ .unsafeFlags([ "-Xfrontend", "-warn-concurrency", "-Xfrontend", "-enable-actor-data-race-checks", ]) ] ),
— 9:21
Building now we will see a whole bunch of warnings anytime we reference DependencyValues.current in an unsafe manner: Reference to static property ‘current’ is not concurrency-safe because it involves shared mutable state
— 9:39
There’s also a bunch of warnings about using Combine publishers in concurrent contexts, and those types are not sendable. We aren’t going to worry about those warnings right now. They are even on the main branch right now, and there isn’t much we can do about them other than make them all @unchecked Sendable , and really sometime in the hopefully not too far future we will be excising all of Combine out of the library, and just using native concurrency tools.
— 10:01
Even beyond threading problems, in multiple places we are wanting to make a change to the DependencyValues for a short amount of time, perform some work, and then restore the values. However, we accomplish this by mutating the global directly before and after the work. It’s totally possible for some outside process to accidentally observe the dependencies while this update-then-restore lifecycle is taking place.
— 10:26
What we really want is the ability to start a new scope of work with the dependencies changed, and only code executed in this new scope can observe the changes to the dependencies. All other code run outside the scope will continue to see the state of the dependencies before the scope was started. And we want to accomplish all of this in a thread safe manner.
— 10:43
Amazingly this is easily accomplished using a new tool from Swift’s concurrency arsenal, and it’s something we talked about quite a bit in our series of episodes covering Swift concurrency from first principles . The tool we want to use is TaskLocal s, which allow us to propagate values throughout an entire application in a thread safe manner, and gives us a tool to open up a new, non-escaping scope where the task local is changed, and only code run in the context of that scope will actually see those changes. All other code will be oblivious to the changes.
— 11:11
Even just the way we are describing this tool is extremely reminiscent of how we describing SwiftUI’s environment and what we want out of our reducer dependencies.
— 11:19
So, let’s see what happens if we try to upgrade our global, mutable value to be a TaskLocal : public struct DependencyValues { @TaskLocal static var current = Self() … }
— 11:30
This causes a number of compiler errors because we are not allowed to reach directly into a task local and mutate it. We are forced to start a new scope via withValue , and then in that scope, and only in that scope, we will have our updated dependencies.
— 11:45
For example, in the Effect type where we were previously mutating the global in place: let output = try await DependencyValues.$current .withValue(before) { try await operation() } … try await DependencyValues.$current .withValue(before) { try await operation(send) }
— 12:40
The only other place we were mutating the global is in DependencyKeyWritingReducer , which now becomes: func reduce( into state: inout Base.State, action: Base.Action ) -> Effect<Base.Action, Never> { var dependencies = DependencyValues.current dependencies[keyPath: self.keyPath] = self.value return DependencyValues.$current .withValue(dependencies) { self.base.reduce(into: &state, action: action) } } And we can now be sure that the only code that observes this mutated dependency is precisely the code run inside the trailing closure of withValue .
— 13:19
This gets things compiling, but we still have a number of warnings.
— 13:23
The first warning is related to the non-sendability of DependencyValues : Type ‘DependencyValues’ does not conform to the ‘Sendable’ protocol
— 13:28
This is a great warning to have, and Swift is catching something legitimate here. We are repeatedly making use of DependencyValues from concurrent contexts, but we have yet to prove to the compiler that it is safe to do so.
— 13:39
We need to make it so that users of the library are not allowed to add their dependencies to our DependencyValues storage unless they have proven their dependencies are Sendable . Let’s see what it takes to accomplish this.
— 13:49
First, we can try making DependencyValues sendable: public struct DependencyValues: Sendable { … }
— 13:54
This brings a new warning on the line where we declare the nebulous storage for our dependencies: private var storage: [AnyHashable: Any] = [:] Stored property ‘storage’ of ‘Sendable’-conforming struct ‘DependencyValues’ has non-sendable type ‘[AnyHashable : Any]’
— 14:00
This of course is not sendable because we are using fully type erased values. We can use any hashable type for the key and absolutely any type for the value, and there are a lot of non-sendable types out there.
— 14:11
Now, we started with AnyHashable because we weren’t yet sure what our keys were actually going to be, but they ended up just being ObjectIdentifier s, which are Sendable , so let’s use that concrete type: private var storage: [ObjectIdentifier: Any] = [:]
— 14:22
The storage is still not Sendable because Any includes a lot of non-sendable things. Well, we can now make use of a powerful Swift 5.7 feature which allows us to directly use an existential type in a lightweight syntax: private var storage: [ObjectIdentifier: any Sendable] = [:]
— 14:38
And that is fixed the warning on that line.
— 14:40
But, we have now have a new warning on the line where we mutate the storage: self.storage[ObjectIdentifier(Key.self)] = newValue Type ‘Key.Value’ does not conform to the ‘Sendable’ protocol
— 14:44
This is complaining that we haven’t proven that Key.Value is Sendable , and so we shouldn’t be allowed to put such a value in the storage.
— 14:50
This is an easy fix. We will simply force that any DependencyKey conformances must provide values that are Sendable : public protocol DependencyKey { associatedtype Value: Sendable static var defaultValue: Value { get } }
— 14:58
This now introduces a few new warnings in places where we are creating new DependencyKey s, but with values that are not proven to be sendable. In particular, we are seeing this in our date and UUID dependency keys: private enum DateKey: DependencyKey { static let defaultValue = { Date() } } private enum UUIDKey: DependencyKey { static let defaultValue = { UUID() } } Type ‘() -> Date’ does not conform to the ‘Sendable’ protocol Type ‘() -> UUID’ does not conform to the ‘Sendable’ protocol
— 15:08
We can fix this by explicitly telling Swift that we don’t expect to use any closure for these dependencies, but only @Sendable ones: private enum DateKey: DependencyKey { static let defaultValue = { @Sendable in Date() } } private enum UUIDKey: DependencyKey { static let defaultValue = { @Sendable in UUID() } } private enum OpenSettingsKey: DependencyKey { static let defaultValue = { @Sendable in … } } private enum TemporaryDirectoryKey: DependencyKey { static let defaultValue = { @Sendable in … } }
— 15:25
And we have to do the same in our extension of DependencyValues : extension DependencyValues { … public var date: @Sendable () -> Date { get { self[DateKey.self] } set { self[DateKey.self] = newValue } } public var uuid: @Sendable () -> UUID { get { self[UUIDKey.self] } set { self[UUIDKey.self] = newValue } } public var openSettings: @Sendable () async -> Void { get { self[OpenSettingsKey.self] } set { self[OpenSettingsKey.self] = newValue } } public var temporaryDirectory: @Sendable () -> URL { get { self[TemporaryDirectoryKey.self] } set { self[TemporaryDirectoryKey.self] = newValue } } }
— 15:36
This now fixes all of the warnings in DependencyValues.swift, but a few more have cropped up in our effect helpers, like these two warnings: Non-sendable type ‘() async throws -> Output’ exiting main actor-isolated context in call to non-isolated instance method ‘withValue(_:operation:file:line:)’ cannot cross actor boundary Non-sendable type ‘Output’ returned by call from main actor-isolated context to non-isolated instance method ‘withValue(_:operation:file:line:)’ cannot cross actor boundary
— 15:46
These are happening because the entire task we are in is marked as @MainActor so that all interacts with the underlying Combine publisher are serialized to the main thread, but the withValue API does not require the closure passed to it to be Sendable , hence the closure exits the @MainActor context in a non-isolated way.
— 16:06
We would expect the fix for this to be something like this: let output = try await DependencyValues.$current .withValue(before) { @MainActor in … }
— 16:09
…but alas, that does not get rid of the warning. We’re not sure if this is a compiler bug, or perhaps even this is not safe to do.
— 16:16
In order to fix this warning we actually need to drop the @MainActor from the Task , and instead manually use await MainActor.run everywhere we want to serialize to the main thread. We won’t take the time to do that now because it’s tedious and not important for the episode, but just know that we will make that change for the final release of the library. Override order and performance
— 16:36
With those changes in place, we’ve fixed most of our concurrency warnings and now have global, mutable storage for our dependencies, all in a thread-safe package.
— 17:17
There’s one tricky aspect to overriding dependencies that we want to mention because it can really help clarify what is happening under the hood. Suppose we overrode a dependency on a child reducer twice: VoiceMemos() … .dependency(\.audioRecorder, .mock) .dependency(\.audioRecorder, .live)
— 17:38
So, we set the audio recorder dependency to a mock client, and then immediately set it to a live client.
— 17:43
What do you expect the actual client to be when we access it from the voice memos reducer? It may seem illogical, but the correct answer is the mock! You may think that the live client should win since it is applied last, but that would allow for some really non-sensical situations.
— 18:02
For example, it would mean that if you wrapped the voice memos reducer in a parent and overrode the dependency: CombineReducers { VoiceMemos() … .dependency(\.audioRecorder, .mock) } .dependency(\.audioRecorder, .live)
— 18:19
…then the live would win for the dependency value, which effectively means that once you override a dependency in a parent layer, no child layer can ever set it again.
— 18:35
So, by making the first dependency in a chain win, we can make a globally more reasonable dependency system.
— 18:52
It’s worth noting that this is the same behavior that SwiftUI adopts, and it’s really the only reasonable choice that can be made. If you were to override a view’s font twice: PlaygroundPage.current.setLiveView( Text("Hello") .font(.title) .font(.callout) )
— 19:08
…it has no choice but to make the first invocation win. Otherwise, if you did something like this: PlaygroundPage.current.setLiveView( Group { Text("Hello") .font(.title) } .font(.callout) )
— 19:25
…it would set the font for all subviews, never allowing the font to be overridden again.
— 19:30
There’s one more improvement we want to make to our dependency overriding operator. Right now if you need to override multiple dependencies, such as what we do in the voice memos preview: VoiceMemos() .dependency(\.audioPlayer, .mock) .dependency(\.audioRecorder, .mock)
— 19:49
…secretly under the hood we are basically nesting multiple withValue calls on the task local: DependencyValues.$current.withValue(…) { DependencyValues.$current.withValue(…) { … } }
— 20:13
This may not seem like a big deal, but changing a task local isn’t necessarily cheap. It needs to do extra work to maintain thread safety, and that work does come with a cost.
— 20:23
We can even see this cost in real terms by writing a quick benchmark. The Composable Architecture library already has an executable in the package where we perform a few basic benchmarks. We can make a new one that compares reading and mutating globals in the old, unsafe style and in the new, task local style: enum Globals { static var value = 42 } enum Locals { @TaskLocal static var value = 42 } benchmark("Locals.value") { precondition(Locals.value == 42) } benchmark("Globals.value") { precondition(Globals.value == 42) } benchmark("Locals.$value.withValue") { Locals.$value.withValue(1729) { precondition(Locals.value == 1729) } } benchmark("Globals.value mutate") { Globals.value = 1729 precondition(Globals.value == 1729) } benchmark("Locals.$value.withValue × 2") { Locals.$value.withValue(1729) { Locals.$value.withValue(42) { precondition(Locals.value == 42) } } } benchmark("Globals.value mutate × 2") { Globals.value = 1729 Globals.value = 42 precondition(Globals.value == 42) } benchmark("Locals.$value.withValue × 3") { Locals.$value.withValue(1729) { Locals.$value.withValue(42) { Locals.$value.withValue(1729) { precondition(Locals.value == 1729) } } } } benchmark("Globals.value mutate × 3") { Globals.value = 1729 Globals.value = 42 Globals.value = 1729 precondition(Globals.value == 1729) } benchmark("Locals.$value.withValue × 4") { Locals.$value.withValue(1729) { Locals.$value.withValue(42) { Locals.$value.withValue(1729) { Locals.$value.withValue(42) { precondition(Locals.value == 42) } } } } } benchmark("Globals.value mutate × 4") { Globals.value = 1729 Globals.value = 42 Globals.value = 1729 Globals.value = 42 precondition(Globals.value == 42) }
— 21:30
Running these benchmarks we will see that there is definitely a cost to using task locals, and the more we nest withValue the most costly it gets: name time std iterations ------------------------------------------------------------- Locals.value 42.000 ns ± 224.93 % 1000000 Globals.value 0.000 ns ± inf % 1000000 Locals.$value.withValue 125.000 ns ± 712.85 % 1000000 Globals.value mutate 0.000 ns ± inf % 1000000 Locals.$value.withValue × 2 167.000 ns ± 1560.46 % 1000000 Globals.value mutate × 2 0.000 ns ± inf % 1000000 Locals.$value.withValue × 3 250.000 ns ± 74.53 % 1000000 Globals.value mutate × 3 0.000 ns ± inf % 1000000 Locals.$value.withValue × 4 333.000 ns ± 1079.52 % 1000000 Globals.value mutate × 4 0.000 ns ± inf % 1000000
— 21:59
Now, it’s important to note that there are a lot of caveats to consider when writing microbenchmarks like this. The Swift compiler could be performing a substantial optimization here for the globals that may not actually happen in real world applications, and so we aren’t actually benchmarking what we think we are.
— 22:15
However, we do think that in general mutating task locals is slower than mutating a global, and further that the more you nest task local mutations, the slower it becomes.
— 22:25
So, we’d like to minimize how much we nest calls to withValue , and there’s a neat trick we can employ to do this.
— 22:32
We can detect if you chain multiple calls to dependency , one after the other, and fuse them all into a single update of the task local. So, when you see something like this: VoiceMemos() .dependency(\.audioPlayer, .mock) .dependency(\.audioRecorder, .mock)
— 22:48
…secretly under the hood it will perform just a single withValue on the task local, and update both dependencies at once.
— 22:56
To accomplish this we can define a new dependency method on the concrete DependencyKeyWritingReducer type with the exact same signature as what we used in the ReducerProtocol extension: private struct DependencyKeyWritingReducer< Base: ReducerProtocol, Value >: ReducerProtocol { … public func dependency<Value>( _ keyPath: WritableKeyPath<DependencyValues, Value>, _ value: Value ) -> some ReducerProtocol<State, Action> { <#???#> } }
— 23:31
This method will be preferred if you chain .dependency directly on another .dependency , allowing us to perform special fusing logic.
— 23:44
In order for the compiler to “see” the override, we need to preserve its type information, so instead of returning an opaque some type, we will return a publicly-known reducer, but we can use an underscore to for the most part hide it from library users. extension ReducerProtocol { public func dependency<Value>( _ keyPath: WritableKeyPath<DependencyValues, Value>, _ value: Value ) -> _DependencyKeyWritingReducer<Self, Value> { … } } public struct _DependencyKeyWritingReducer< Base: ReducerProtocol, Value >: ReducerProtocol { … }
— 24:22
In order to perform the fusion we can no longer hold onto a key path or value because we need to be able to fuse multiple changes to DependencyValues , each of which may change a completely different type of dependency. No one single key path can encapsulate that kind of behavior.
— 24:41
Instead, we will hold onto an update function that takes an inout DependencyValues to represent the changes we want to make to the dependencies, which means we can even drop the Value generic from the reducer: extension ReducerProtocol { public func dependency<Value>( _ keyPath: WritableKeyPath<DependencyValues>, _ value: Value ) -> _DependencyKeyWritingReducer<Self> { … } } public struct _DependencyKeyWritingReducer< Base: ReducerProtocol >: ReducerProtocol { let base: Base let update: (inout DependencyValues) -> Void … }
— 25:02
Then the reduce method changes to apply the update function rather than mutating via the key path: func reduce( into state: inout Base.State, action: Base.Action ) -> Effect<Base.Action, Never> { var dependencies = DependencyValues.current self.update(&dependencies) return DependencyValues.$current .withValue(dependencies) { self.base.reduce(into: &state, action: action) } }
— 25:15
And we can update the non-fused dependency method to supply an update closure that just applies the key path mutation: extension ReducerProtocol { public func dependency<Value>( _ keyPath: WritableKeyPath<DependencyValues, Value>, _ value: Value ) -> some ReducerProtocolOf<Self> { DependencyKeyWritingReducer(base: self) { values in values[keyPath: keyPath] = value } } }
— 25:33
And we can finish off the fusion dependency method by returning a brand new DependencyKeyWritingReducer with a new trailing update closure that applies both the new key path mutation and the previous update mutation: public func dependency<Value>( _ keyPath: WritableKeyPath<DependencyValues, Value>, _ value: Value ) -> Self { _DependencyKeyWritingReducer(base: self.base) { values in values[keyPath: keyPath] = value self.update(&values) } }
— 26:13
Note that the order of these two lines is important. As we mentioned a moment ago, when chaining on two dependency operators the only sensible decision we can make is to have the first chained operator win over all other later dependencies. So, we first apply the newest key path mutation, and then apply the previous update mutations, which allows earlier usages of the dependency to override later ones.
— 26:39
Everything now compiles, and it should work exactly as it did before. And we can see the fusion in effect by printing out the type of a reducer with many chained dependencies: let _ = print( type( of: VoiceMemos() .dependency(\.audioPlayer, .mock) .dependency(\.audioRecorder, .mock) .dependency(\.audioRecorder, .live) ) ) _DependencyKeyWritingReducer<VoiceMemos>
— 26:39
We have a simple, flat nesting that shows our fusion working. And we can verify this by commenting out the overload responsible for fusion: _DependencyKeyWritingReducer< _DependencyKeyWritingReducer< _DependencyKeyWritingReducer<VoiceMemos> > >
— 27:53
It’s worth taking a moment to reflect on the power of this new dependency management system, and why it’s somewhat unique to the Composable Architecture. The reason we are able to create a global storage of dependencies that can be mutated and scoped for child features is specifically because features built in the Composable Architecture have a single entry point for its behavior, and that the reduce method of the ReducerProtocol : func reduce( into state: inout State, action: Action ) -> Effect<Action, Never>
— 28:22
This means if you want to change a dependency for the entire execution lifetime of a child feature, it’s as simple as this: DependencyValues.$current.withValue(…) { Child().reduce(…) }
— 28:55
This little bit of code completely alters the execution context of the Child feature, including all of its effects, its effects’ effects, its child features, and their effects, and on and on and on.
— 29:05
This gives us a concise way of controlling dependencies that is completely sound, well-defined and will not give us any surprises in the future. On the flip side, dependency management in vanilla SwiftUI, UIKit and other UI frameworks can be fraught with edge cases and inconsistencies. Just take a look any of the dependency injection frameworks out there in the Swift community and you will find a zoo of types and terminology for dealing with all the complexity inherent in those UI frameworks.
— 29:32
Now, none of this is the fault of the UI frameworks or dependency injection frameworks. They have decided to prioritize different things than the Composable Architecture has, and some of their strengths are our weaknesses, just as some our of strengths are their weaknesses.
— 29:46
The only reason this style of dependency management is possible in the Composable Architecture is because our feature’s have one single entry point into their behavior, which is the reduce method: Child().reduce(…)
— 29:56
In vanilla SwiftUI applications you have an ObservableObject with its behavior scattered across a bunch of method endpoints: class VoiceMemosModel: ObservableObject { … func recordButtonTapped() { … } func deleteButtonTapped() { … } }
— 30:18
It’s not possible to encapsulate the entirety of this model’s behavior in an execution context. Something like this doesn’t work: DependencyValues.$current.withValue(…) { let voiceMemos = VoiceMemosModel() }
— 30:33
…because only the initializer will be executed in the context, none of the object’s other methods.
— 30:40
The same goes for view controllers: class VoiceMemosViewController: UIViewController { … func recordButtonTapped() { … } func deleteButtonTapped() { … } } We can over course try something like this: DependencyValues.$current.withValue(…) { let voiceMemos = VoiceMemosViewController() } …but it isn’t going to do what we want. None of the methods on voiceMemos will be executed in the context we have set up.
— 30:56
This is because those forms of implementing feature behavior do not have a single endpoint for all of their logic and behavior. It’s scattered across multiple methods and properties.
— 31:08
What’s interesting is that although the logic and behavior of SwiftUI applications are built in this style, the view layer of SwiftUI is built in this style. It’s the only reason why environment values work.
— 31:24
All views in SwiftUI have a single entry point into computing their view hierarchy so that something can be displayed on the screen, which means if we evaluate the body in a execution context with some environment values changed: EnvironmentValues.$current.withValue(…) { ChildView().body }
— 31:57
…then the content view and all of its children views will all see the updated environment values.
— 32:05
So, one can think of the Composable Architecture as a natural extension of SwiftUI where we simply require that a feature’s entire logic and behavior be encapsulated in a single entry point, just as views have a single entry point. Viewed from this perspective we think that the Composable Architecture is faithful to the spirit of SwiftUI’s philosophical foundations, and we do not think it goes against the grain at all. Next time: testing
— 32:34
So this is pretty incredible.
— 32:35
We have vastly overhauled how dependencies are handled in the library, as long as you are using the new ReducerProtocol style of building your features. You can declare that a reducer wants a dependency by just using the @Dependency property wrapper, and you don’t even have to explicitly pass that dependency when constructing the reducer. It is automatically provided to the reducer behind the scenes.
— 32:54
This means you no longer need to provide an initializer for your reducers or environment just to pass dependencies from the parent layer down to the child, and you can even add, remove or change dependencies in a deep, leaf node of your application, and you won’t have to make a single change in any of the parent layers.
— 33:12
Further, it is also possible to make tweaks to the dependencies a reducer uses from the outside. We saw this a moment ago in previews where we wanted to preview our feature in a more controlled environment, one that did not actually interact with AVFoundation. But you can apply this idea also to running a portion of your application in a different execution environment. For example, if you have an onboarding experience for teaching people how to use a feature, you can run that feature with altered dependencies so that they don’t interact with the real world, like save data to disk, make database requests, or hit the network.
— 33:46
That allows you to run your feature in a kind of sandbox, so that you can fully control what your user experiences during onboarding.
— 33:52
So, this all seems great, but can you believe it even gets better?
— 33:56
We’ve now had multiple episodes about reducer protocols and we haven’t talked about testing once. Testing is a top priority for the library. We never want to make changes or introduce features that can hurt testability. It turns out that our move towards reducer protocols and this new style of dependencies only improves the testing story for the library. It allows us to codify a pattern directly into the library that makes your tests stronger and more exhaustive. And we’ll look at that…next time! Downloads Sample code 0206-reducer-protocol-pt6 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 .