In my previous article, I talked about the need to create boundaries and structure around TCA Actions.
Let’s build on top of that:
- Introduce improved APIs
- Add compile-time errors if someone tries to break the boundaries
- Showcase a more experimental Reducer creation
Build Errors
Consistent Structure
Remember our TCAFeatureAction protocol?
protocol TCAFeatureAction {
associatedtype ViewAction
associatedtype DelegateAction
associatedtype InternalAction
static func view(_: ViewAction) -> Self
static func delegate(_: DelegateAction) -> Self
static func internal(_: InternalAction) -> Self
}
We leverage enum cases working as protocol witnesses and enforce all our feature enums will be adhering to this structure:
enum Action: TCAFeatureAction {
enum ViewAction: Equatable {}
enum InternalAction: Equatable {
enum Subset: Equatable {
case ignore
}
case dataLoaded
case subset(Subset)
case userSettings(UserSettingsFeature.Action)
case tasks(TasksFeature.Action)
}
enum DelegateAction: Equatable {}
case view(ViewAction)
case delegate(DelegateAction)
case _internal(InternalAction)
}
When it comes to embedding child features, those are internal implementation details. As such, we pack them under the InternalAction subset.
Access rules
For embedded child features, you can only access **one level deep *and only via delegate action, both *internal* and *view are forbidden.
Why not allow more than 1 level of embedding?
If you were building a standard OOP program, and you have a FeatureA, then the consumers of that feature shouldn’t know how that feature is implemented, the same rules apply here.
FeatureA **-[embeed]-> **FeatureB -[embeed]-> FeatureC
If I’m integrating FeatureX, I should only react to FeatureX.Action.Delegate set. Everything else is an implementation detail.
Call sites
Let’s look at the call sites we want to allow:
case ._internal(.userSettings(.delegate)):
case ._internal(.dataLoaded):
case ._internal(.subset(.ignore)):
case let ._internal(.subset(action)):
case ._internal(.userSettings), ._internal(.tasks):
And those that we want to forbid:
case ._internal(.userSettings(.view(action))):
case ._internal(.userSettings(.view(.onDayEndChanged))):
case ._internal(.userSettings(._internal))
case ._internal(.userSettings(.view))
Linting
We will write a custom rule for SwiftLint to automate those rules.
💡
Keep in mind that it’s still possible to access those private actions if someone really wants to, but we want to automate default cases that would happen due to mistakes, not something that folks would abuse on purpose.
Let’s break up the regex I’ve used for this into parts:
._internal- If we are matching against internal actions of the root set\(\s*((\w+.)|.)- and we either useFullyQualifiedFeature.Action. or *.name*`* with an opening brace, ignoring whitespaces/newlines at the start*\w+- we match action name\(\s*((\w+.)|.)- we match fully qualified or regular names as in point 2.(view|_internal).*?- we look for eitherviewor_internalsince those aren’t allowed and lazy match until the end of the string
The entire rule setup I use is:
tca_feature_actions:
name: "Boundries"
regex: '\._internal\(\s*((\w+\.)*|\.)\w+\(\s*((\w+\.)*|\.)(view|_internal).*?' # matching pattern
capture_group: 5
match_kinds:
- identifier
message: "Only access Delegate actions of directly embeeded features."
severity: error
SwiftLint rule
Our rule now throws compile time errors in our code, preventing accidental misuse of the API.
Build Errors
API Conveniences
💡
This consistent structure allows us to build some small conveniences around the pattern, when considering whether we should add it to not we should weigh the benefit they bring vs the learning curve we’d add to the team.
Reducer Protocol
Given our nested structure, we can’t automatically create a CasePath to our embedded features, like we do with the vanilla action setup.
What we need to do instead is to append two CasePath together:
/Action._internal .. /Action.InternalAction.userSettings
Doesn’t read well and breaks Xcode highlighting:
Broken Xcode highlighting
But we can make this nicer by adding a simple extension:
extension Scope where ParentAction: TCAFeatureAction {
@inlinable
public init(
state toChildState: WritableKeyPath<ParentState, Child.State>,
action toChildAction: CasePath<ParentAction.InternalAction, Child.Action>,
@ReducerBuilderOf<Child> _ child: () -> Child
) {
self = .init(state: toChildState, action: (/ParentAction._internal) .. toChildAction, child)
}
}
Now we can write a single case path and the type inference will understand what we meant:
Scope(state: \.settings, action: /Action.InternalAction.userSettings) {
UserSettingsFeature()
}
View Layer
We run into the same issue in the View layer:
store.scope(
state: \.settings,
action: (/AppFeature.Action._internal .. /AppFeature.Action.InternalAction.userSettings).embed)
)
Again we leverage consistent structure to write an extension:
extension Store where Action: TCAFeatureAction {
func scope<ChildState, ChildAction>(
state toChildState: @escaping (State) -> ChildState,
action fromChildAction: CasePath<Action.InternalAction, ChildAction>
) -> Store<ChildState, ChildAction> {
scope(
state: toChildState,
action: { ._internal(fromChildAction.embed($0)) }
)
}
}
Then our view layer scoping becomes simple again:
store.scope(
state: \.settings,
action: /AppFeature.Action.InternalAction.userSettings
)
Experimental Reducer creation
💡
I don’t really recommend the following extension because I prefer the standard TCA setup and the freedom it gives. Still, it’s interesting to see what consistent API structure lets us build.
The two APIs I’ve introduced above are life improvements and don’t change how TCA works, but this structure could allow you do to more crazy things, for example, you could change how reducers are implemented.
With an extension to the TCAFeatureProtocol that adds casePaths for scoping you could create a version of reducer creation that not only has separate actions passed through (ViewAction + Internal) but prevents you from reacting to your delegate actions or forwarding to view actions.
extension Reducer where Action: TCAFeatureAction {
enum AllowedActions {
case delegate(Action.DelegateAction)
case reducer(Action.InternalAction)
}
static func make(
view viewReducer: @escaping (inout State, Action.ViewAction, Environment) -> Effect<AllowedActions, Never>,
reducer reducerReducer: @escaping (inout State, Action.InternalAction, Environment) -> Effect<AllowedActions, Never>
) -> Reducer<State, Action, Environment> {
.init { state, action, environment in
if let viewAction = Action.viewCasePath.extract(from: action) {
let effect = viewReducer(&state, viewAction, environment)
return effect.map {
switch $0 {
case let .delegate(delegate):
return Action.delegateCasePath.embed(delegate)
case let .reducer(reducer):
return Action.internalCasePath.embed(reducer)
}
}
} else if let reducerAction = Action.reducerCasePath.extract(from: action) {
let effect = reducerReducer(&state, reducerAction, environment)
return effect.map {
switch $0 {
case let .delegate(delegate):
return Action.delegateCasePath.embed(delegate)
case let .reducer(reducer):
return Action.internalCasePath.embed(reducer)
}
}
}
return .none
}
}
}
Then your reducer could become this:
let todoReducer = Reducer<TodoState, TodoAction, Void>.make(
view: { state, action, environment in
switch action {
case .didRename: return .none
case .didAppear:
let newTodos: [Todo] = [.init(name: "Wash Car", complete: false), .init(name: "Goto Gym", complete: true)]
return .task { ._internal(.listResult(.success(newTodos))) }
case .toggle(let todo):
return .task { ._internal(.toggleResult(.success(.init(id: todo.id, name: todo.name, complete: !todo.complete)))) }
case .dismissError:
state.error = nil
return .none
}
},
internal: { state, action, _ in
switch action {
case .listResult(.success(let todos)):
state.todos = .init(uniqueElements: todos)
return .none
case .toggleResult(.success(let todo)):
state.todos[id: todo.id] = todo
return .none
case .listResult(.failure(let error)), .toggleResult(.failure(let error)):
state.error = error
return .none
}
}
)
Conclusion
Having a consistent and type-defined structure (protocols in our case) for our Architecture allows for many useful API extensions, dev tools, and more safety.
PS. I am very excited that The Composable Architecture is moving in that direction as well, new ReducerProtocol adds more types and consistency into our Features and will yield better performance!