Skip to content
Go back

TCA Action Boundries - Convenience

In my previous article, I talked about the need to create boundaries and structure around TCA Actions.

Let’s build on top of that:

  1. Introduce improved APIs
  2. Add compile-time errors if someone tries to break the boundaries
  3. 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:

  1. ._internal - If we are matching against internal actions of the root set
  2. \(\s*((\w+.)|.) - and we either use FullyQualifiedFeature.Action. or *.name*`* with an opening brace, ignoring whitespaces/newlines at the start*
  3. \w+ - we match action name
  4. \(\s*((\w+.)|.) - we match fully qualified or regular names as in point 2.
  5. (view|_internal).*? - we look for either view or _internal since 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!


Share this post on:

Previous Post
Improving Developer Experience through tools and techniques
Next Post
TCA Action Boundaries