Introducing TCA Composer - A swift macro framework for eliminating boiler-plate code in TCA Reducers #2766
scogeo
started this conversation in
Show and tell
Replies: 1 comment 1 reply
-
|
Hi @scogeo, thanks for sharing! The macros certainly pack a punch! We will also be announcing a few updates to |
Beta Was this translation helpful? Give feedback.
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Intro
I was inspired by the recent Point-Free Macro Bonanza to explore some ideas I have had for some time in reducing boilerplate in TCA-based apps. Macros seemed like the perfect way to accomplish this. The project began with a modest goal of simplifying how to use scopes in TCA. Instead of writing code like
store.scope(state: \.child, action: \.child), I wanted to more concisely writestore.scopes.child. That turned out to be fairly easy to implement and my mind was then filled with possibilities of what else I could accomplish with macros. So I began incrementally adding new capabilities to my macro framework using my own apps as a test bed and playground.The final result is called TCA Composer. I'm happy to release it publicly today after a couple months of development in private. Over the coming days and weeks I will be improving the documentation, adding additional examples, and implementing a few additional capabilities that many may find useful.
Example
To show you how TCA Composer works, let's start with a simple
TwoCountersexample that uses the canonicalCounterreducer to combining twoCounterreducers into a single reducer and add some basic functions such as optionally displaying the sum of the two counters and providing a button to reset both counters to zero.First, let's see how this
Reducerwould be implemented normally:While this is very straightforward to implement, it is necessary to make code changes in three spearate places for each child reducer, once in each of
Action,State, and thebody. The changes are trivial, but can be error prone in practice, especially when you introduce optional state, identified arrays, navigation, etc. In addition, complex reducers and SwiftUIViews can encounter the dreadedThe compiler is unable to type-check this expression in reasonable time;error from simple typos and syntax errors.Composer simplfies all of this so you can define your child reducers in a single place, and it will generate all of the necessary boilerplate for you. It also generates what I call a
ScopePathfor each child reducer, allowing you to scope stores using dynamic member lookup on a newscopesoperation on an extension ofStore.Using the
TCAComposerlibrary here is what theTwoCountersreducer looks like:Let's see what code was generated. Click to expand the
@Composermacro@ComposeReducer( .bindable, children: [ .reducer("counter1", of: Counter.self, initialState: .init()), .reducer("counter2", of: Counter.self, initialState: .init()) ] ) @Composer struct TwoCounters { + @_ComposerScopePathable + @_ComposedStateMember("counter1", of: Counter.State.self, initialValue: .init()) + @_ComposedStateMember("counter2", of: Counter.State.self, initialValue: .init()) + @ObservableState struct State { var isDisplayingSum = false } + @CasePathable enum ViewAction { case resetCountersTapped } @ComposeBodyActionCase func view(state: inout State, action: ViewAction) { switch action { case .resetCountersTapped: state.counter1.count = 0 state.counter2.count = 0 } } + @CasePathable + enum Action: ComposableArchitecture.BindableAction, ComposableArchitecture.ViewAction { + case binding(BindingAction<State>) + case counter1(Counter.Action) + case counter2(Counter.Action) + case view(ViewAction) + } + + @ComposableArchitecture.ReducerBuilder<Self.State, Self.Action> + var body: some ReducerOf<Self> { + ComposableArchitecture.BindingReducer() + ComposableArchitecture.Scope(state: \.counter1, action: \Action.Cases.counter1) { + Counter() + } + ComposableArchitecture.Scope(state: \.counter2, action: \Action.Cases.counter2) { + Counter() + } + ComposableArchitecture.CombineReducers { + TCAComposer.ReduceAction(\Action.Cases.view) { state, action in + self.view(state: &state, action: action) + return .none + } + } + } + + struct AllComposedScopePaths { + var counter1: TCAComposer.ScopePath<TwoCounters.State, Counter.State, TwoCounters.Action, Counter.Action> { + get { + return TCAComposer.ScopePath(state: \State.counter1, action: \Action.Cases.counter1) + } + } + var counter2: TCAComposer.ScopePath<TwoCounters.State, Counter.State, TwoCounters.Action, Counter.Action> { + get { + return TCAComposer.ScopePath(state: \State.counter2, action: \Action.Cases.counter2) + } + } + } }Wow, that's a lot code! Composer has automatically generated an
Actionthat includes conformance forBindingActionthanks to the.bindableoption. TheActionalso incorporates cases for our two reducer children and theviewaction from@ComposeBodyActionCasemacro. The automatically generatedbodycalls theBindingReducer, scopes the two child reducers and then finally invokes ourviewfunction to reduce theViewAction.You will also notice that new macros appear that begin with an underscore are attached to
State. These are internal macros that Composer uses to generate code in portions of yourReducercode and are a byproduct of how the swift macro system works. The internal macros are not meant to be used by you and may change from release to release. Here's what they look like when fully expanded forState:@ObservableState struct State { var isDisplayingSum = false + static var allComposedScopePaths: AllComposedScopePaths { + AllComposedScopePaths() + } + + @ObservationStateTracked + var counter1: Counter.State = .init() + @ObservationStateIgnored + private var _counter1: Counter.State + + @ObservationStateTracked + var counter2: Counter.State = .init() + @ObservationStateIgnored + private var _counter2: Counter.State }The macros automatically added new members to
Statefor our child reducers including the required support for@ObservableState. The@_ComposerScopePathablemacro combined with the generatedAllComposedScopePathsstruct provides support for improving view ergonomics by generating aScopePathfor each child reducer so that you can scope a child reducer usingstore.scopes.counter1, rather than the more verbosestore.scope(state: \.counter1, action: \.counter1). Pretty cool, eh?Summary
Composer can do this and so much more. For more information, please checkout the the tca-composer repo.
If you have any comments or questions about Composer please post them on my announcement discussion in my repo.
Beta Was this translation helpful? Give feedback.
All reactions