├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Tests └── TCABoundariesTests │ └── TCABoundariesTests.swift ├── Sources └── TCABoundaries │ ├── BoundedReducers │ ├── ComposedBoundedReducer.swift │ └── BoundingReducer.swift │ └── BoundedActions │ ├── TCAFeatureAction.swift │ └── Extensions │ ├── TCAFeatureAction+Store.swift │ ├── TCAFeatureAction+ForEachReducer.swift │ ├── TCAFeatureAction+IfLetReducer.swift │ └── TCAFeatureAction+Scope.swift ├── Package.swift ├── .gitignore ├── Package.resolved └── README.md /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/TCABoundariesTests/TCABoundariesTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import TCABoundaries 3 | 4 | final class TCABoundariesTests: XCTestCase { 5 | func testExample() throws { 6 | XCTAssertTrue(true) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/TCABoundaries/BoundedReducers/ComposedBoundedReducer.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import ComposableArchitecture 3 | 4 | public protocol ComposedBoundingReducer: BoundingReducer { 5 | @ReducerBuilder var body: Body { get } 6 | } 7 | 8 | public extension ComposedBoundingReducer { 9 | func reduceCore(into state: inout State, action: Action) -> Effect { 10 | if let action = action[case: \.view] { 11 | return reduce(into: &state, viewAction: action) 12 | } 13 | if let action = action[case: \._internal] { 14 | return reduce(into: &state, internalAction: action) 15 | } 16 | if let action = action[case: \.delegate] { 17 | return reduce(into: &state, delegateAction: action) 18 | } 19 | return .none 20 | } 21 | 22 | var coreReducer: Reduce { 23 | Reduce(reduceCore) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "tca-boundaries", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | ], 14 | products: [ 15 | .library( 16 | name: "TCABoundaries", 17 | targets: [ 18 | "TCABoundaries" 19 | ] 20 | ), 21 | ], 22 | dependencies: [ 23 | .package( 24 | url: "https://github.com/pointfreeco/swift-composable-architecture.git", 25 | from: "1.22.3" 26 | ), 27 | ], 28 | targets: [ 29 | .target( 30 | name: "TCABoundaries", 31 | dependencies: [ 32 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 33 | ] 34 | ), 35 | .testTarget( 36 | name: "TCABoundariesTests", 37 | dependencies: [ 38 | "TCABoundaries" 39 | ] 40 | ), 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /Sources/TCABoundaries/BoundedReducers/BoundingReducer.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import ComposableArchitecture 3 | 4 | public protocol BoundingReducer: Reducer where Action: TCAFeatureAction { 5 | func reduce( 6 | into state: inout State, 7 | viewAction action: Action.ViewAction 8 | ) -> Effect 9 | 10 | func reduce( 11 | into state: inout State, 12 | internalAction action: Action.InternalAction 13 | ) -> Effect 14 | 15 | func reduce( 16 | into state: inout State, 17 | delegateAction action: Action.DelegateAction 18 | ) -> Effect 19 | } 20 | 21 | public extension BoundingReducer { 22 | func reduce( 23 | into state: inout State, 24 | internalAction action: Action.InternalAction 25 | ) -> ComposableArchitecture.Effect { .none } 26 | 27 | func reduce( 28 | into state: inout State, 29 | delegateAction action: Action.DelegateAction 30 | ) -> Effect { .none } 31 | } 32 | 33 | public extension BoundingReducer where Body == Never { 34 | func reduce(into state: inout State, action: Action) -> Effect { 35 | if let viewAction = action[case: \.view] { 36 | return reduce(into: &state, viewAction: viewAction) 37 | } 38 | 39 | if let internalAction = action[case: \._internal] { 40 | return reduce(into: &state, internalAction: internalAction) 41 | } 42 | 43 | if let delegateAction = action[case: \.delegate] { 44 | return reduce(into: &state, delegateAction: delegateAction) 45 | } 46 | 47 | return .none 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /Sources/TCABoundaries/BoundedActions/TCAFeatureAction.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import ComposableArchitecture 3 | import Foundation 4 | 5 | /// The `TCAFeatureAction` defines a pattern for actions on TCA based on https://www.merowing.info/boundries-in-tca/ 6 | /// Its idea is to set a well defined specification for actions on TCA views, where ideally View and Internal actions should not go out of the view scope. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// enum ExampleAction: TCAFeatureAction { 11 | /// enum ViewAction: Equatable { 12 | /// case onTapLoginButton 13 | /// } 14 | /// 15 | /// enum DelegateAction: Equatable { 16 | /// case notifyLoginSuccess 17 | /// } 18 | /// 19 | /// enum InternalAction: Equatable { 20 | /// case loginResult(Result) 21 | /// } 22 | /// 23 | /// case view(ViewAction) 24 | /// case delegate(DelegateAction) 25 | /// case _internal(InternalAction) 26 | /// } 27 | /// ``` 28 | /// 29 | /// Conforming types automatically satisfy ``ComposableArchitecture/ViewAction`` so they can adopt 30 | /// conveniences like the ``ComposableArchitecture/ViewAction(for:)`` macro. You should still 31 | /// leverage ``CasePathable`` synthesis (for example by annotating the reducer with ``Reducer()`` or 32 | /// the action enum with ``CasePathable``) so that case key paths can be composed ergonomically when 33 | /// interacting with the latest versions of the Composable Architecture. 34 | public protocol TCAFeatureAction: CasePathable, Equatable, ComposableArchitecture.ViewAction { 35 | /// `DelegateAction` relates to actions that are delegate to parent components (like the well known Delegate pattern) 36 | associatedtype DelegateAction: Equatable 37 | /// `InternalAction` relates to actions that happen inside the Reducer's scope, like: handling results, internal/reused actions and such 38 | associatedtype InternalAction: Equatable 39 | 40 | static func delegate(_: DelegateAction) -> Self 41 | static func _internal(_: InternalAction) -> Self 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Custom 6 | .DS_Store 7 | 8 | ## User settings 9 | xcuserdata/ 10 | 11 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 12 | *.xcscmblueprint 13 | *.xccheckout 14 | 15 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 16 | build/ 17 | DerivedData/ 18 | *.moved-aside 19 | *.pbxuser 20 | !default.pbxuser 21 | *.mode1v3 22 | !default.mode1v3 23 | *.mode2v3 24 | !default.mode2v3 25 | *.perspectivev3 26 | !default.perspectivev3 27 | 28 | ## Obj-C/Swift specific 29 | *.hmap 30 | 31 | ## App packaging 32 | *.ipa 33 | *.dSYM.zip 34 | *.dSYM 35 | 36 | ## Playgrounds 37 | timeline.xctimeline 38 | playground.xcworkspace 39 | 40 | # Swift Package Manager 41 | # 42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 | # Packages/ 44 | # Package.pins 45 | # Package.resolved 46 | # *.xcodeproj 47 | # 48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 49 | # hence it is not needed unless you have added a package configuration file to your project 50 | # .swiftpm 51 | 52 | .build/ 53 | 54 | # CocoaPods 55 | # 56 | # We recommend against adding the Pods directory to your .gitignore. However 57 | # you should judge for yourself, the pros and cons are mentioned at: 58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 59 | # 60 | # Pods/ 61 | # 62 | # Add this line if you want to avoid checking in source code from the Xcode workspace 63 | # *.xcworkspace 64 | 65 | # Carthage 66 | # 67 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 68 | # Carthage/Checkouts 69 | 70 | Carthage/Build/ 71 | 72 | # Accio dependency management 73 | Dependencies/ 74 | .accio/ 75 | 76 | # fastlane 77 | # 78 | # It is recommended to not store the screenshots in the git repo. 79 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 80 | # For more information about the recommended setup visit: 81 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 82 | 83 | fastlane/report.xml 84 | fastlane/Preview.html 85 | fastlane/screenshots/**/*.png 86 | fastlane/test_output 87 | 88 | # Code Injection 89 | # 90 | # After new code Injection tools there's a generated folder /iOSInjectionProject 91 | # https://github.com/johnno1962/injectionforxcode 92 | 93 | iOSInjectionProject/ 94 | -------------------------------------------------------------------------------- /Sources/TCABoundaries/BoundedActions/Extensions/TCAFeatureAction+Store.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import ComposableArchitecture 3 | 4 | /// `ViewOnlyStoreOf` For use when scoping down to a store of only `ViewAction` 5 | public typealias ViewOnlyStoreOf = Store 6 | 7 | /// `ViewOnlyViewStoreOf` For use when scoping down to a viewstore of only `ViewAction` 8 | public typealias RestrictedViewStore = ViewStore 9 | 10 | extension Store where Action: TCAFeatureAction { 11 | /// Convenience var to quickly scope a store to just it's `ViewAction` 12 | public var viewScope: Store { 13 | scope(state: \.self, action: \.view) 14 | } 15 | /// When you have a `Child` flow inside a `Parent` store and need to scope it is often expressed as an `InternalAction` of the `Parent` store. 16 | /// This can lead to a bit a an awkward API when trying to express that relation... 17 | /// ```swift 18 | /// enum ParentAction: TCAFeatureAction { 19 | /// enum InternalAction: Equatable { 20 | /// // ... 21 | /// case child(ChildAction) 22 | /// } 23 | /// // ... 24 | /// case view(ViewAction) 25 | /// case _internal(InternalAction) 26 | /// case delegate(DelegateAction) 27 | /// } 28 | /// ``` 29 | /// Without this extension we would have to write something like: 30 | /// ```swift 31 | /// struct ParentView: View { 32 | /// let store: Store 33 | /// var body: some View { 34 | /// // ... 35 | /// ChildView( 36 | /// store.scope( 37 | /// state: \.childState, 38 | /// action: \ParentAction.Cases._internal.child 39 | /// ) 40 | /// ) 41 | /// // ... 42 | /// } 43 | /// } 44 | /// ``` 45 | /// With this exntesion we are able to get a cleaner API like below: 46 | /// ```swift 47 | /// struct ParentView: View { 48 | /// let store: Store 49 | /// var body: some View { 50 | /// // ... 51 | /// ChildView( 52 | /// store.scope( 53 | /// state: \.childState, 54 | /// action: \ParentAction.Cases._internal.child 55 | /// ) 56 | /// ) 57 | /// // ... 58 | /// } 59 | /// } 60 | /// ``` 61 | /// - Parameters: 62 | /// - toChildState: A key path that transforms `State` into `ChildState`. 63 | /// - toChildAction: A case key path that transforms `Action.InternalAction` into `ChildAction`. 64 | /// - Returns: A new store with its domain (state and action) transformed. 65 | public func scope( 66 | state toChildState: KeyPath, 67 | action toChildAction: CaseKeyPath 68 | ) -> Store { 69 | scope( 70 | state: toChildState, 71 | action: (\Action.Cases._internal).appending(path: toChildAction) 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/TCABoundaries/BoundedActions/Extensions/TCAFeatureAction+ForEachReducer.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | 3 | extension Reducer where Action: TCAFeatureAction { 4 | /// Embeds a child reducer in a parent domain that works on elements of a collection in parent 5 | /// state. 6 | /// 7 | /// For example, if a parent feature holds onto an array of child states, then it can perform 8 | /// its core logic _and_ the child's logic by using the `forEach` operator: 9 | /// 10 | /// ```swift 11 | /// struct Parent: Reducer { 12 | /// struct State { 13 | /// var rows: IdentifiedArrayOf 14 | /// // ... 15 | /// } 16 | /// enum Action { 17 | /// case row(id: Row.State.ID, action: Row.Action) 18 | /// // ... 19 | /// } 20 | /// 21 | /// var body: some Reducer { 22 | /// Reduce { state, action in 23 | /// // Core logic for parent feature 24 | /// } 25 | /// .forEach(\.rows, action: \.row) { 26 | /// Row() 27 | /// } 28 | /// } 29 | /// } 30 | /// ``` 31 | /// 32 | /// > Tip: We are using `IdentifiedArray` from our 33 | /// [Identified Collections][swift-identified-collections] library because it provides a safe 34 | /// and ergonomic API for accessing elements from a stable ID rather than positional indices. 35 | /// 36 | /// The `forEach` forces a specific order of operations for the child and parent features. It 37 | /// runs the child first, and then the parent. If the order was reversed, then it would be 38 | /// possible for the parent feature to remove the child state from the array, in which case the 39 | /// child feature would not be able to react to that action. That can cause subtle bugs. 40 | /// 41 | /// It is still possible for a parent feature higher up in the application to remove the child 42 | /// state from the array before the child has a chance to react to the action. In such cases a 43 | /// runtime warning is shown in Xcode to let you know that there's a potential problem. 44 | /// 45 | /// [swift-identified-collections]: http://github.com/pointfreeco/swift-identified-collections 46 | /// 47 | /// - Parameters: 48 | /// - toElementsState: A writable key path from parent state to an `IdentifiedArray` of child 49 | /// state. 50 | /// - toElementAction: A case path from parent action to child identifier and child actions. 51 | /// - element: A reducer that will be invoked with child actions against elements of child 52 | /// state. 53 | /// - Returns: A reducer that combines the child reducer with the parent reducer. 54 | @inlinable 55 | @warn_unqualified_access 56 | public func forEach( 57 | _ toElementsState: WritableKeyPath>, 58 | action toElementAction: CaseKeyPath, 59 | @ReducerBuilder element: () -> Element, 60 | fileID: StaticString = #fileID, 61 | line: UInt = #line 62 | ) -> _ForEachReducer where ElementState == Element.State, ElementAction == Element.Action { 63 | self.forEach( 64 | toElementsState, 65 | action: (\Action.Cases._internal).appending(path: toElementAction), 66 | element: element, 67 | fileID: fileID, 68 | line: line 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/TCABoundaries/BoundedActions/Extensions/TCAFeatureAction+IfLetReducer.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | 3 | extension Reducer where Action: TCAFeatureAction { 4 | /// Embeds a child reducer in a parent domain that works on an optional property of parent state. 5 | /// 6 | /// For example, if a parent feature holds onto a piece of optional child state, then it can 7 | /// perform its core logic _and_ the child's logic by using the `ifLet` operator: 8 | /// 9 | /// ```swift 10 | /// struct Parent: Reducer { 11 | /// struct State { 12 | /// var child: Child.State? 13 | /// // ... 14 | /// } 15 | /// enum Action { 16 | /// case child(Child.Action) 17 | /// // ... 18 | /// } 19 | /// 20 | /// var body: some Reducer { 21 | /// Reduce { state, action in 22 | /// // Core logic for parent feature 23 | /// } 24 | /// .ifLet(\.child, action: \.child) { 25 | /// Child() 26 | /// } 27 | /// } 28 | /// } 29 | /// ``` 30 | /// 31 | /// The `ifLet` operator does a number of things to try to enforce correctness: 32 | /// 33 | /// * It forces a specific order of operations for the child and parent features. It runs the 34 | /// child first, and then the parent. If the order was reversed, then it would be possible for 35 | /// the parent feature to `nil` out the child state, in which case the child feature would not 36 | /// be able to react to that action. That can cause subtle bugs. 37 | /// 38 | /// * It automatically cancels all child effects when it detects the child's state is `nil`'d 39 | /// out. 40 | /// 41 | /// * Automatically `nil`s out child state when an action is sent for alerts and confirmation 42 | /// dialogs. 43 | /// 44 | /// See ``Reducer/ifLet(_:action:destination:fileID:line:)`` for a more advanced operator 45 | /// suited to navigation. 46 | /// 47 | /// - Parameters: 48 | /// - toWrappedState: A writable key path from parent state to a property containing optional 49 | /// child state. 50 | /// - toWrappedAction: A case path from parent action to a case containing child actions. 51 | /// - wrapped: A reducer that will be invoked with child actions against non-optional child 52 | /// state. 53 | /// - Returns: A reducer that combines the child reducer with the parent reducer. 54 | @inlinable 55 | @warn_unqualified_access 56 | public func ifLet( 57 | _ toWrappedState: WritableKeyPath, 58 | action toWrappedAction: CaseKeyPath, 59 | @ReducerBuilder then wrapped: () -> Wrapped, 60 | fileID: StaticString = #fileID, 61 | line: UInt = #line 62 | ) -> _IfLetReducer where WrappedState == Wrapped.State, WrappedAction == Wrapped.Action { 63 | self.ifLet( 64 | toWrappedState, 65 | action: (\Action.Cases._internal).appending(path: toWrappedAction), 66 | then: wrapped, 67 | fileID: fileID, 68 | line: line 69 | ) 70 | } 71 | 72 | @inlinable 73 | @warn_unqualified_access 74 | public func ifLet( 75 | _ toWrappedState: WritableKeyPath, 76 | action toWrappedAction: CaseKeyPath, 77 | fileID: StaticString = #fileID, 78 | line: UInt = #line 79 | ) -> _IfLetReducer> { 80 | self.ifLet( 81 | toWrappedState, 82 | action: (\Action.Cases._internal).appending(path: toWrappedAction), 83 | fileID: fileID, 84 | line: line 85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "5928286acce13def418ec36d05a001a9641086f2", 9 | "version" : "1.0.3" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-case-paths", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-case-paths", 16 | "state" : { 17 | "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", 18 | "version" : "1.7.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-clocks", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-clocks", 25 | "state" : { 26 | "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", 27 | "version" : "1.0.6" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections", 34 | "state" : { 35 | "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", 36 | "version" : "1.2.1" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-composable-architecture", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", 43 | "state" : { 44 | "revision" : "2d60d4082dfb4978974307acf0f00dfa20e5f621", 45 | "version" : "1.22.3" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-concurrency-extras", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 52 | "state" : { 53 | "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", 54 | "version" : "1.3.2" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-custom-dump", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 61 | "state" : { 62 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 63 | "version" : "1.3.3" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-dependencies", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/pointfreeco/swift-dependencies", 70 | "state" : { 71 | "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", 72 | "version" : "1.9.5" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-identified-collections", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 79 | "state" : { 80 | "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", 81 | "version" : "1.1.1" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-navigation", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/pointfreeco/swift-navigation", 88 | "state" : { 89 | "revision" : "6b7f44d218e776bb7a5246efb940440d57c8b2cf", 90 | "version" : "2.4.2" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-perception", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/pointfreeco/swift-perception", 97 | "state" : { 98 | "revision" : "30721accd0370d7c9cb5bd0f7cdf5a1a767b383d", 99 | "version" : "2.0.8" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-sharing", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/pointfreeco/swift-sharing", 106 | "state" : { 107 | "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", 108 | "version" : "2.7.4" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-syntax", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/swiftlang/swift-syntax", 115 | "state" : { 116 | "revision" : "4799286537280063c85a32f09884cfbca301b1a1", 117 | "version" : "602.0.0" 118 | } 119 | }, 120 | { 121 | "identity" : "xctest-dynamic-overlay", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 124 | "state" : { 125 | "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", 126 | "version" : "1.6.1" 127 | } 128 | } 129 | ], 130 | "version" : 2 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TCABoundaries 2 | 3 | The `TCAFeatureAction` defines a pattern for actions on TCA based on https://www.merowing.info/boundries-in-tca/ 4 | Its idea is to set a well defined specification for actions on TCA views, where ideally View and Internal actions should not go out of the feature scope. 5 | 6 | Example: 7 | ```swift 8 | enum ExampleAction: TCAFeatureAction { 9 | enum ViewAction: Equatable { 10 | case onTapLoginButton 11 | } 12 | 13 | enum DelegateAction: Equatable { 14 | case notifyLoginSuccess 15 | } 16 | 17 | enum InternalAction: Equatable { 18 | case loginResult(Result) 19 | } 20 | 21 | case view(ViewAction) 22 | case delegate(DelegateAction) 23 | case _internal(InternalAction) 24 | } 25 | ``` 26 | 27 | ## Example app 28 | 29 | Below is a small counter feature that demonstrates how the boundaries pattern works together with 30 | the latest Composable Architecture APIs. 31 | 32 | ```swift 33 | import ComposableArchitecture 34 | import TCABoundaries 35 | import SwiftUI 36 | 37 | @Reducer 38 | struct CounterFeature: BoundingReducer { 39 | struct State: Equatable { 40 | var count = 0 41 | } 42 | 43 | enum Action: TCAFeatureAction { 44 | enum ViewAction: Equatable { 45 | case decrementButtonTapped 46 | case incrementButtonTapped 47 | } 48 | 49 | enum DelegateAction: Equatable { 50 | case finished 51 | } 52 | 53 | enum InternalAction: Equatable { 54 | case timerUpdated 55 | } 56 | 57 | case view(ViewAction) 58 | case delegate(DelegateAction) 59 | case _internal(InternalAction) 60 | } 61 | 62 | func reduce(into state: inout State, viewAction action: Action.ViewAction) -> Effect { 63 | switch action { 64 | case .decrementButtonTapped: 65 | state.count -= 1 66 | return .none 67 | 68 | case .incrementButtonTapped: 69 | state.count += 1 70 | return .none 71 | } 72 | } 73 | } 74 | 75 | @ViewAction(for: CounterFeature.self) 76 | struct CounterView: View { 77 | let store: StoreOf 78 | 79 | var body: some View { 80 | WithPerceptionTracking { 81 | VStack { 82 | Text("\(store.count)") 83 | 84 | HStack { 85 | Button("−") { send(.decrementButtonTapped) } 86 | Button("+") { send(.incrementButtonTapped) } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | ``` 93 | 94 | ## Examples 95 | ### Child flow on parent, with boundaries 96 | When you have a `Child` flow inside a `Parent` store and need to scope it is often expressed as an `InternalAction` of the `Parent` store. 97 | ```swift 98 | enum ParentAction: TCAFeatureAction { 99 | enum InternalAction: Equatable { 100 | // ... 101 | case child(ChildAction) 102 | } 103 | // ... 104 | case view(ViewAction) 105 | case _internal(InternalAction) 106 | case delegate(DelegateAction) 107 | } 108 | ``` 109 | This will be expressed like below: 110 | ```swift 111 | // On the reducer 112 | Scope(state: \.child, action: /Action.InternalAction.child) { 113 | ChildFeature() 114 | } 115 | // On the view 116 | struct ParentView: View { 117 | let store: StoreOf 118 | 119 | var body: some View { 120 | // ... 121 | ChildView( 122 | store.scope( 123 | state: \.childState, 124 | action: /ParentAction.InternalAction.child 125 | ) 126 | ) 127 | // ... 128 | } 129 | } 130 | ``` 131 | 132 | 133 | # Bounding Reducers 134 | Bounding reducers is a protocol to define a standard when implementing reducers with Boundaries. 135 | We can have them on composed (reducers with body) or non-composed reducers. 136 | 137 | The main idea relies on setting a standard for separating the actions based on its type on specific functions: 138 | 139 | ```swift 140 | // To handle actions coming from the view 141 | func reduce(into state: inout State, viewAction action: Action.ViewAction) -> Effect 142 | 143 | // To handle actions that happen inside the reducer 144 | func reduce(into state: inout State, internalAction action: Action.InternalAction) -> Effect 145 | 146 | // To handle actions that where delegated to this reducer 147 | func reduce(into state: inout State, delegateAction action: Action.DelegateAction) -> Effect 148 | ``` 149 | 150 | Example for non-composed reducers: 151 | ```swift 152 | struct SomeFeature: BoundingReducer { 153 | func reduce(into state: inout State, viewAction action: Action.ViewAction) -> Effect { 154 | switch action { 155 | ... 156 | } 157 | } 158 | 159 | func reduce(into state: inout State, internalAction action: Action.InternalAction) -> Effect { 160 | switch action { 161 | ... 162 | } 163 | } 164 | 165 | ... 166 | } 167 | ``` 168 | 169 | Example for composed reducers: 170 | ```swift 171 | struct SomeFeature: ComposedBoundingReducer { 172 | var body: some Reducer { 173 | coreReducer 174 | .ifLet(\.child, action: /Action.InternalAction.child) { 175 | ChildFeature() 176 | } 177 | } 178 | 179 | func reduce(into state: inout State, viewAction action: Action.ViewAction) -> Effect { 180 | switch action { 181 | ... 182 | } 183 | } 184 | ... 185 | } 186 | ``` 187 | 188 | ## Installation 189 | 190 | You can add TCABoundaries to an Xcode project by adding it as a package dependency. 191 | 192 | 1. From the File menu, select Add Packages... 193 | 2. Enter "https://github.com/bocato/tca-boundaries" into the package repository URL text field 194 | -------------------------------------------------------------------------------- /Sources/TCABoundaries/BoundedActions/Extensions/TCAFeatureAction+Scope.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | 3 | extension Scope where ParentAction: TCAFeatureAction { 4 | /// When you have a `Child` flow inside a `Parent` store and need to scope it is often expressed as an `InternalAction` of the `Parent` store. 5 | /// This can lead to a bit a an awkward API when trying to express that relation... 6 | /// ```swift 7 | /// enum ParentAction: TCAFeatureAction { 8 | /// enum InternalAction: Equatable { 9 | /// // ... 10 | /// case child(ChildAction) 11 | /// } 12 | /// // ... 13 | /// case view(ViewAction) 14 | /// case _internal(InternalAction) 15 | /// case delegate(DelegateAction) 16 | /// } 17 | /// ``` 18 | /// Without this extension we would have to write something like: 19 | /// ```swift 20 | /// Scope(\Action.Cases._internal.child) { 21 | /// ChildFeature() 22 | /// } 23 | /// ``` 24 | /// With this extension we are able to get a cleaner API like below: 25 | /// ```swift 26 | /// Scope(state: \.child, action: \ParentAction.Cases._internal.child) { 27 | /// ChildFeature() 28 | /// } 29 | /// ``` 30 | /// - Parameters: 31 | /// - state: A key path that transforms `State` into `ChildState`. 32 | /// - action: A case key path that transforms `Action.InternalAction` into `ChildAction`. 33 | /// - child: The reducer builder for the Child. 34 | @inlinable 35 | public init( 36 | state toChildState: WritableKeyPath, 37 | action toChildAction: CaseKeyPath, 38 | _ child: () -> Child 39 | ) { 40 | self = .init( 41 | state: toChildState, 42 | action: (\ParentAction.Cases._internal).appending(path: toChildAction), 43 | child: child 44 | ) 45 | } 46 | 47 | /// Initializes a reducer that runs the given child reducer against a slice of parent state and 48 | /// actions. 49 | /// 50 | /// Useful for combining child reducers into a parent. 51 | /// 52 | /// ```swift 53 | /// var body: some Reducer { 54 | /// Scope(state: \.profile, action: \.profile) { 55 | /// Profile() 56 | /// } 57 | /// Scope(state: \.settings, action: \.settings) { 58 | /// Settings() 59 | /// } 60 | /// // ... 61 | /// } 62 | /// ``` 63 | /// 64 | /// - Parameters: 65 | /// - toChildState: A writable key path from parent state to a property containing child state. 66 | /// - toChildAction: A case path from parent action to a case containing child actions. 67 | /// - child: A reducer that will be invoked with child actions against child state. 68 | @inlinable 69 | public init( 70 | state toChildState: WritableKeyPath, 71 | action toChildAction: CaseKeyPath, 72 | @ReducerBuilder child: () -> Child 73 | ) where ChildState == Child.State, ChildAction == Child.Action { 74 | self.init( 75 | state: toChildState, 76 | action: (\ParentAction.Cases._internal).appending(path: toChildAction), 77 | child: child 78 | ) 79 | } 80 | 81 | /// Initializes a reducer that runs the given child reducer against a slice of parent state and 82 | /// actions. 83 | /// 84 | /// Useful for combining reducers of mutually-exclusive enum state. 85 | /// 86 | /// ```swift 87 | /// var body: some Reducer { 88 | /// Scope(state: \.loggedIn, action: \.loggedIn) { 89 | /// LoggedIn() 90 | /// } 91 | /// Scope(state: \.loggedOut, action: \.loggedOut) { 92 | /// LoggedOut() 93 | /// } 94 | /// } 95 | /// ``` 96 | /// 97 | /// > Warning: Be careful when assembling reducers that are scoped to cases of enum state. If a 98 | /// > scoped reducer receives a child action when its state is set to an unrelated case, it will 99 | /// > not be able to process the action, which is considered an application logic error and will 100 | /// > emit runtime warnings. 101 | /// > 102 | /// > This can happen if another reducer in the parent domain changes the child state to an 103 | /// > unrelated case when it handles the action _before_ the scoped reducer runs. For example, a 104 | /// > parent may receive a dismissal action from the child domain: 105 | /// > 106 | /// > ```swift 107 | /// > Reduce { state, action in 108 | /// > switch action { 109 | /// > case .loggedIn(.quitButtonTapped): 110 | /// > state = .loggedOut(LoggedOut.State()) 111 | /// > // ... 112 | /// > } 113 | /// > } 114 | /// > Scope(state: \.loggedIn, action: \.loggedIn) { 115 | /// > LoggedIn() // ⚠️ Logged-in domain can't handle `quitButtonTapped` 116 | /// > } 117 | /// > ``` 118 | /// > 119 | /// > If the parent domain contains additional logic for switching between cases of child state, 120 | /// > prefer ``Reducer/ifCaseLet(_:action:then:fileID:line:)``, which better ensures that 121 | /// > child logic runs _before_ any parent logic can replace child state: 122 | /// > 123 | /// > ```swift 124 | /// > Reduce { state, action in 125 | /// > switch action { 126 | /// > case .loggedIn(.quitButtonTapped): 127 | /// > state = .loggedOut(LoggedOut.State()) 128 | /// > // ... 129 | /// > } 130 | /// > } 131 | /// > .ifCaseLet(\.loggedIn, action: \.loggedIn) { 132 | /// > LoggedIn() // ✅ Receives actions before its case can change 133 | /// > } 134 | /// > ``` 135 | /// 136 | /// - Parameters: 137 | /// - toChildState: A case path from parent state to a case containing child state. 138 | /// - toChildAction: A case path from parent action to a case containing child actions. 139 | /// - child: A reducer that will be invoked with child actions against child state. 140 | @inlinable 141 | public init( 142 | state toChildState: CaseKeyPath, 143 | action toChildAction: CaseKeyPath, 144 | @ReducerBuilder child: () -> Child, 145 | fileID: StaticString = #fileID, 146 | line: UInt = #line 147 | ) where ChildState == Child.State, ChildAction == Child.Action { 148 | self.init( 149 | state: toChildState, 150 | action: (\ParentAction.Cases._internal).appending(path: toChildAction), 151 | child: child, 152 | fileID: fileID, 153 | line: line 154 | ) 155 | } 156 | } 157 | --------------------------------------------------------------------------------