I've been a fan of Point Free Composable architecture for a while now.
I've worked on TCA projects for more than a year on projects of all sizes: from smaller projects like my indie Sourcery Pro, through the New York Times project, to a truly large codebase, a completely new browser for The Browser Company.
TCA has been working great in all of them, but one thing that I was missing was an easy way to debug state changes, as the official debug
higher-order reducer doesn't play well with large app states.
So I thought: let's create an interface that will not only deal with larger states, but also offer us a way to filter actions with ease:
.debugDiffing(allowedActions: .allExcept(.hoverActions, .windowVisibility))
Problems to solve
Large diffs
The first problem is the amount of data TCA will print out whenever an action changes its state. ? In case of bigger apps it's simply won't be readable to humans because it prints the whole state and just marks the change.
Sure, you can attach the debug
in a specific module and that should make it more manageable - but when you're trying to understand all interactions between different behaviors in-app, that's not a real solution.
Solution
Fortunately, there is one ready-to-use solution. I've built it for TDD workflows a few years ago. It's called
Difference and it generates exact property level difference between 2 objects.
Leveraging difference will let you get debug logs like this one:
🎬 Received action:
AppAction.windows(
WindowsAction.window(
windowID: WindowID(
rawValue: "131DDF0A-DDE2-4757-B1AC-4DAB60269E8D"
),
action: WindowAction.toggleListCollapsedState(
listID: RootOrListIdentifier.list(
Identifier<SidebarItem>(
rawValue: "F686B64A-E221-49FB-9504-FE003FC6493F"
)
)
)
)
)
🖨️ State:
persistedWindows:
| windows:
| | Key 131DDF0A-DDE2-4757-B1AC-4DAB60269E8D:
| | | collapsedListIDs:
| | | | Different count:
| | | | | Current: (1)
| | | | | Previous: (0)
Action filtering
When dealing with state debugging at the app level we often want to suppress messages from a particular set of actions, e.g. at Browser Company we get actions for things like windows visibility or mouse hover state. Those things can easily get noisy, so it's good to have a way to suppress it.
To do that, you could simply add closure for filtering actions, such as:
.debugDiffing(allowedActions: { action in
switch action {
case .windows(_, action: .visibility):
return false
default:
return true
}
})
That could definitely work, but it would also mean that all the filtering code gets deleted the moment you are done with debugging.
So, the next person that tries to debug the app will have to repeat the same kind of work. Not very efficient or convenient, right?
Action repository
Instead, let's build a solution that:
- enables you to accumulate an action filter repository that can be used by the next person debugging the codebase,
- has got a nice composable DSL for readability and leveraging IntelliSense.
To achieve the above we can introduce a generic wrapper over Action
check called ActionFilter
:
public struct ActionFilter<Action: Equatable> {
let isIncluded: (Action) -> Bool
func callAsFunction(_ action: Action) -> Bool {
isIncluded(action)
}
/// Include all actions
public static var all: Self {
.init(isIncluded: { _ in true })
}
/// negates the filter
public static func not(_ filter: Self) -> Self {
.init(isIncluded: { !filter($0) })
}
/// Allows all actions except those specified
public static func allExcept(_ actions: [Self]) -> Self {
.init(isIncluded: { action in
!actions.contains(where: { $0(action) })
})
}
/// Allows any of the specified actions
public static func anyOf(_ actions: [Self]) -> Self {
.init(isIncluded: { action in
actions.contains(where: { $0(action) })
})
}
By leveraging variadic argument support in Swift you can enable an even nicer API when dealing with multiple filters and, therefore, simply forward the call to the array variant you already created:
/// Allows all actions except those specified
public static func allExcept(_ actions: Self...) -> Self {
allExcept(actions)
}
By leveraging callAsFunction
feature you can call the filters as one would do with standard closures. Using generic means that you can write specialized extensions for different Action
types in our projects:
extension ActionFilter where Action == AppAction {
static var sidebarActions: Self {
Self(isIncluded: {
switch $0 {
case .appSidebar, .sidebarSync, .sidebarSyncCloudKit:
return true
default:
return false
}
})
}
This means that now you can have a central repository of filters related to AppAction
and you can leverage IntelliSense when debugging AppAction
.
Connecting it all together
Now you just want to take TCA original debug reducer implementation interface and modify it to fit your needs:
extension Reducer where State: Equatable, Action: Equatable {
public func debugDiffing(
actionFormat: ActionFormat = .prettyPrint,
allowedActions: ActionFilter<Action> = .all
) -> Self {
.init { state, action, env in
let oldState = state
let effects = self.run(&state, action, env)
let newState = state
/// Leverage callAsFunction
guard allowedActions(action) else {
return effects
}
return .merge(
.fireAndForget {
debugEnvironment.queue.async {
let actionOutput =
actionFormat == .prettyPrint
? debugOutput(action).indent(by: 2)
: debugCaseOutput(action).indent(by: 2)
var stateOutput: String = "🖨️ (No state changes)"
if oldState != newState {
/// Leverage Difference
stateOutput = diff(oldState, newState, indentationType: .pipe, skipPrintingOnDiffCount: true, nameLabels: .comparing).joined(separator: ", ")
}
debugEnvironment.printer(
"""
🎬 Received action:
\(actionOutput)
🖨️ State:
\(stateOutput)
"""
)
}
},
effects
)
}
}
This allows you to append debugDiffing(...)
call to any reducer that has got equatable state/action pairs.
Conclusion
I believe this reducer to be a significant improvement for a majority of TCA-based applications.
It leverages smaller diffs and adds the ability to easily filter out actions that you are not interested in.
This interface not only leverages a couple of language features which enable convenient DSL and IntelliSense, but also improves the future workflows since the ActionFilter
repository grows with each developer.
Full gist is available on my github
Do you like working with TCA-based architecture? The Browser Company is hiring and they are one of the largest projects leveraging this architecture that I've seen.