This library brings an API similar to SwiftUI's Environment
to derive and compose Environment
's in The Composable Architecture (TCA).
TCA is moving toward protocol reducers. This simplifies greatly the way dependencies are passed around between features. It is encouraged to migrate toward this approach. TCA will also use a @Dependency
property wrapper, and a DependencyKey
protocol, so you will need to perform a few actions to have both systems working at the same time while you transition out from this library.
By Environment
, one understands a type that vends dependencies. This library eases this process by standardizing these dependencies, and the way they are passed from one environment type to another when composing domains using TCA. Like in SwiftUI, this library allows passing values (in this case dependencies) down a tree of values (in this case the reducers) without having to specify them at each step. You don't need to provide initial values for dependencies in your Environment
's, you don't need to inject dependencies from a parent environment to a child environment, and in many cases, you don't even need to instantiate the child environment.
This library comes with two mutually exclusive modules, ComposableEnvironment
and GlobalEnvironment
, which are providing different functionalities for different tradeoffs.
ComposableEnvironment
allows defining environments where dependencies can be overridden at any point in the reducer chain. Like in SwiftUI, setting a value for a dependency propagates downstream until it is eventually overridden again.
GlobalEnvironment
allows defining global dependencies that are the same for all reducers in the chain. This is the most frequent configuration.
Both modules are defined in the same repository to maintain source compatibility between them.
The GlobalEnvironment
module should fit most of the cases.
Each dependency we want to share should be declared with a DependencyKey
's in a similar fashion one declares custom EnvironmentValue
's in SwiftUI using EnvironmentKey
's. Let define a mainQueue
dependency:
struct MainQueueKey: DependencyKey {
static var defaultValue: AnySchedulerOf<DispatchQueue> { .main }
}
This key doesn't need to be public. If the dependency is an existential type, it can be even used as a DependencyKey
itself, without needing to introduce an additional type.
Like we would do with SwiftUI's EnvironmentValues
, we also install it in Dependencies
:
extension Dependencies {
var mainQueue: AnySchedulerOf<DispatchQueue> {
get { self[MainQueueKey.self] }
set { self[MainQueueKey.self] = newValue }
}
}
Whereas you're using ComposableEnvironment
or GlobalEnvironment
, there are distinct ways to access your dependencies.
You use the @Dependency
property wrapper to expose a dependency to your environment. This property wrapper takes as argument the KeyPath
of the property you defined in Dependencies
. For example, to expose the mainQueue
defined above, you declare
@Dependency(\.mainQueue) var main
Note that you don't need to provide a value for the dependency. The effective value for this property is the current value from the environment, or the default
value if you defined none.
You can also already use a subscript from your Environment
to directly access the dependency without having to expose it. You use this subscript with the KeyPath
from the property defined in Dependencies
. For example:
environment[\.mainQueue]
returns the same value as @Dependency(\.mainQueue)
.
Whereas you use one or another is up to you. The implicit subscript is faster, but some prefer having explicit declarations to assess the environment's dependencies.
When using ComposableEnvironment
, you can directly access a dependency by using its computed property name in Dependencies
from any ComposableEnvironment
subclass, even if you did not expose the dependency using the @Dependency
property wrapper:
environment.mainQueue
This direct access is unfortunately not possible when using GlobalEnvironment
.
The way you define environments differs, whereas you're using ComposableEnvironment
or GlobalEnvironment
.
When using ComposableEnvironment
, all your environments need to be subclasses of ComposableEnvironment
. This is unfortunately required to automatically handle the storage of the private environment values state at a given node. Let define the ParentEnvironment
exposing the mainQueue
dependency:
public class ParentEnvironment: ComposableEnvironment {
@Dependency(\.mainQueue) var main
}
Imagine that you need to embed a Child
TCA feature into the Parent
feature. You declare the embedding using the @DerivedEnvironment
property wrapper:
public class ParentEnvironment: ComposableEnvironment {
@Dependency(\.mainQueue) var main
@DerivedEnvironment<ChildEnvironment> var child
}
When you access the child
property of ParentEnvironment
, it automatically inherit the dependencies from ParentEnvironment
. You can pullback childReducer
using the standard methods:
childReducer.pullback(state: \.child, action: /ParentAction.child, environment: \.child)
You can assign a value to the child environment inline with its declaration, or let the library handle the initialization of an instance for you. In this last case, you can even embed the child reducer using environment-less pullbacks:
childReducer.pullback(state: \.child, action: /ParentAction.child)
Note: If you use an environment-less pullback, any initial value you may have defined inline will be discarded. In this case, you should use standard pullbacks with the \.child
KeyPath
as a function (ParentEnvironment) -> ChildEnvironment
.
When using GlobalEnvironment
, your environment, whereas it's a value or a reference type, should conform to the GlobalEnvironment
protocol.
You can then define and use your dependencies in the same way as for ComposableEnvironment
. As all dependencies are globally shared and there are no specific dependencies to inherit, it makes less sense to use the @DerivedEnvironment
property wrapper if you're not using it to define dependency aliases (see below).
public struct ParentEnvironment: GlobalEnvironment {
public init() {}
@Dependency(\.mainQueue) var main
}
You still have access to environment-less pullbacks, with the same API:
childReducer.pullback(state: \.child, action: /ParentAction.child)
The only requirement for GlobalEnvironment
is to provide an init()
initializer. If this is not possible for your child environment, you can still implement the GlobalDependenciesAccessing
marker protocol which has no requirements but gives your type access to global dependencies using the implicit subscript accessors. You can also do nothing and use the @Dependency
which has no restriction over its host like the ComposableEnvironment
version has (it needs to be installed in a ComposableEnvironment
subclass).
If you can't conform to GlobalEnvironment
, you only lose access to the environment-less pullbacks.
Once dependencies are defined as computed properties of the Dependencies
, you only access them through your environment, whereas it's a ComposableEnvironment
subclass or some type conforming to GlobalDependenciesAccessing
.
To set a value to a dependency, you use the with(keyPath,value)
chainable method from your environment:
environment
.with(\.mainQueue, DispatchQueue.main)
.with(\.uuidGenerator, { UUID() })
…
When you're using GlobalEnvironment
, each dependency is set globally. If you set the same dependency twice, the last call prevails.
When you're using ComposableEnvironment
, each dependency is set along the dependency tree until it eventually is set again using a with(keyPath, anotherValue)
call on a child environment. This works in the same fashion as SwiftUI Environment
.
In the case the same dependency was defined by different domains using different computed properties in Dependencies
, you can alias them using the aliasing(dependencyKeyPath, to: referenceDependencyKeyPath)
chainable method from your environment. For example, if you defined the main queue as .main
in some feature, and as mainQueue
in another, you can alias both using
environment.aliasing(\.main, to: \.mainQueue)
Once aliased, you can assign a value using either KeyPath
. If no value is set for the dependency, the second argument provides its default for both KeyPaths
.
You can also alias dependencies "on the spot", using the @DerivedEnvironment
property wrapper. Its initializer provides a closure transforming a provided AliasBuilder
.
This type has only one chainable method, alias(dependencyKeyPath, to: referenceDependencyKeyPath)
. For example, if the main
dependency is defined in the child
derived environment, you can define an alias to the mainQueue
dependency from ParentEnvironment
using:
public class ParentEnvironment: ComposableEnvironment {
@Dependency(\.mainQueue) var mainQueue
@DerivedEnvironment<ChildEnvironment>(aliases: {
$0.alias(\.main, to: \.mainQueue)
}) var child
}
When using this property wrapper, you don't need to define the alias from the environment using .aliasing()
.
Dependencies aliases are always global.
You can forgo @DerivedEnvironment
declarations when:
- You don't need to customize dependencies specifically for the child environment when using
ComposableEnvironment
. - You don't need to alias dependencies "on the spot", using the
@DerivedEnvironment
property wrapper, when using either module. The example app shows how this feature can be used and mixed with the property-wrapper approach when usingComposableEnvironment
.
When your environment can be instantiated automatically, you can use environment-less pullbacks:
childReducer.pullback(state: \.child, action: /ParentAction.child)
// or, for collections of features:
childReducer.forEach(state: \.children, action: /ParentAction.children)
Please note that in order to access such pullbacks when using GlobalEnvironment
, your environment needs to conform to the GlobalEnvironment
protocol.
As a rule of thumb, if you need to modify your dependencies in the middle of the environment's tree, you should use ComposableEnvironment
. If all dependencies are shared across your environments, you should use GlobalEnvironment
. As the first configuration is quite rare, we recommend using GlobalEnvironment
if you're in doubt, as it is the simplest to implement in an existing TCA project.
The principal differences between the two approaches are summarized in the following table:
ComposableEnvironment |
GlobalEnvironment |
|
---|---|---|
Environment Type | Classes | Any existential (struct, classes, etc.) |
Environment Tree | All nodes should be ComposableEnvironment subclasses |
Free, can opt-in/opt-out at any point |
Dependency values | Customizable per instance | Globally defined |
Access to dependencies | @Dependency , direct, implicit |
@Dependency , implicit |
In order to ease its learning curve, the library bases its API on SwiftUI's Environment. We have the following functional correspondences:
SwiftUI | ComposableEnvironment | Usage |
---|---|---|
EnvironmentKey |
DependencyKey |
Identify a shared value |
EnvironmentValues |
Dependencies |
Expose a shared value |
@Environment |
@Dependency |
Retrieve a shared value |
View |
(Composable/Global)Environment |
A node |
View.body |
@DerivedEnvironment 's |
A list of children of the node |
View .environment(keyPath:value:) |
(Composable/Global)Environment .with(keyPath:value:) |
Set a shared value for a node and its children |
The latest documentation for ComposableEnvironment's APIs is available here.
Add
.package(url: "https://github.com/tgrapperon/swift-composable-environment", from: "0.5.0")
to your Package dependencies in Package.swift
, and then
.product(name: "ComposableEnvironment", package: "swift-composable-environment")
// or
.product(name: "GlobalEnvironment", package: "swift-composable-environment")
to your target's dependencies, depending on the module you want to use.
When importing the latest versions of TCA, there will likely be ambiguities when using the @Dependency
property wrapper, or the DependencyKey
protocol. As this library will give way to TCA, the preferred approach is the following:
- Define a typealias to both TCA's types in a module you own (or in your application if you're not using modules):
import ComposableArchitecture
public typealias DependencyKey = ComposableArchitecture.DependencyKey
public typealias Dependency = ComposableArchitecture.Dependency
Because these typeliases are defined in modules you own, they will be preferred to external definitions when resolving types.
- Replace all occurences of
DependencyKey
byCompatible.DependencyKey
and@Dependency
by@Compatible.Dependency
. You can use Xcode search/replace in all files for this purpose.
In this state, your project should build without ambiguities.
DependencyKey: TCA's DependencyKey
@Dependency: TCA's @Dependency
---
Compatible.DependencyKey: Composable Environment's DependencyKey
@Compatible.Dependency: Composable Environment's @Dependency
You can then migrate at your rhythm to protocol reducers. Once the migration is complete, you can remove the dependency to this library. I hope it served you well!