TCA in SwiftUI: The Composable Architecture or just The Complicated Antipattern? And no, MVVM is not the alternative!
Let’s make an antipattern so complex that it becomes an architecture!
Gerard ’t Hooft (Nobel Prize in Physics, 1999): People accept complexity much more easily than simplicity. Simple explanations invite endless objections, while complex ones are rarely questioned.
Introduction
The Composable Architecture (TCA) has gained a devoted following in SwiftUI circles. Its core principle is to model all user and domain behavior as an enum called Action and handle it through a Reducer. While this seems appealing at first glance, it is, in reality, a textbook set of antipatterns: encoding behavior as data and hijacking state ownership.
TCA forces you to wrap everything in enums, forward child actions, compose reducers, and switch exhaustively over all cases — creating unnecessary indirection, boilerplate, and coupling of state and behavior. This article will explain why TCA is fundamentally flawed and propose a pure data oriented alternative that aligns perfectly with SwiftUI.
The Problem: Enum-based Actions — A Glorified Tag Dispatch
In TCA, a simple counter requires this ceremony:
import ComposableArchitecture
// 1. Feature with @Reducer macro
@Reducer
struct CounterFeature {
// 2. State
@ObservableState
struct State: Equatable {
var count: Int = 0
}
// 3. Action enum
enum Action {
case increment
case decrement
case reset
}
// 4. Reducer body
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .increment:
state.count += 1
return .none
case .decrement:
state.count -= 1
return .none
case .reset:
state.count = 0
return .none
}
}
}
}
// 5. SwiftUI View (no more WithViewStore!)
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
Button("+") { store.send(.increment) }
Button("-") { store.send(.decrement) }
Button("Reset") { store.send(.reset) }
}
}
}This pattern has several serious issues:
- Behavior is encoded as data. The reducer acts as an interpreter over enum tags. Encoding behavior as tagged data interpreted elsewhere is a known smell. This corresponds to control-coupling and type-code dispatch, both documented as design antipatterns in software architecture (see references below, Fowler).
- State and behavior are coupled. Every action requires access to the reducer and the state. Composition is done via nested enums, not functions. Separating state from behavior is a core principle in reactive / data-flow systems (see references below).
- Boilerplate explodes. Every new action requires updating multiple switches, forwarding child actions, and wrapping/unwrapping enums.
- Extensibility is painful. Adding new behavior requires touching multiple files and breaking modularity.
TCA solves a problem it creates: it attempts to “organize actions” by encoding them, but in reality, it is reinventing a dispatcher/interpreter that Swift already does much more elegantly through closures and pure functions.
TCA: Reinventing the Wheel
Beyond the enum-based action antipattern, TCA commits another architectural flaw: it hijacks state ownership from SwiftUI and reinvents features that already exist natively.
State Ownership Hijacking
SwiftUI has a declarative state management system built into its runtime:
@Statefor local view state@Observablefor reference-type modelsDynamicPropertyprotocol for custom state containers- Automatic dependency tracking and view invalidation
TCA ignores all of this. Instead, it:
- Wraps state in a
Storeobject (low in the hierarchy) - Forces you to use
ViewStoreto access state - Replaces SwiftUI’s observation with its own subscription system
- Breaks the “source of truth” principle by hiding state inside reducers
Why this is bad:
- Fights SwiftUI’s declarative model — SwiftUI expects state to live in the view hierarchy, not buried in a Store
- Adds unnecessary indirection —
WithViewStoreis a wrapper around a wrapper - Breaks native dependency tracking — SwiftUI can’t optimize rendering because state is opaque
- Duplicates observation logic — TCA reimplements what
@Observablealready does
SwiftUI already knows how to manage state. TCA says “no thanks” and builds its own system on top.
Dependency Injection: A Glorified Singleton
TCA also reinvents dependency injection with its Environment type:
struct AppEnvironment {
var apiClient: APIClient
var analytics: Analytics
// ... more dependencies
}let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
// Use environment.apiClient, etc.
}This is a glorified singleton dressed up as “dependency injection.”
What SwiftUI already provides:
@Environmentfor injecting values down the view hierarchy- Protocol-based injection via initializers
- Observation-based dependency passing with
@Observable
TCA’s Environment adds:
- More boilerplate (every reducer needs the full environment signature)
- Tight coupling (changing dependencies requires updating reducer signatures)
- No compile-time safety (dependencies are passed as a bag of values)
TCA reinvents data flow, state management, and dependency injection — all of which SwiftUI handles natively and elegantly.
SwiftUI already offers unidirectional data flow. But it was too simple and not cool. TCA provides something much cooler:
The Antipattern in Theory
The mistake is clear:
Encoding behavior as an enum and dispatching it through a giant switch is the tag-dispatch antipattern, or “interpreter pattern gone wrong.” The preferred approach is to represent behavior as data, specifically as closures, that you can simply use, no need for encoding them as enums.
This is also known as “encoding strategy” — reifying behavior into data structures instead of using functions directly. The result is unnecessary indirection, loss of composability, and violation of functional programming principles.
Enums should represent state, not behavior. When actions are functions, composition becomes trivial, modularity is preserved, and the system remains purely functional.
The Alternative: The Capability Pattern
Expose behavior as closures that can be passed around, composed, and called directly — no enums, no switches, no interpreters.
As Alan Perlis famously said:
“It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures.”
The inverse is equally true: Don’t encode 100 behaviors as data when you could use 100 functions.
Why This Works in Swift
The capability pattern leverages Swift’s core strengths:
1. Value Semantics
- Closures capture state by value or reference as needed
- No hidden mutable state — everything is explicit
- Predictable behavior without side effects
2. First-Class Functions
- Closures are data, but they model behavior directly
- No indirection through enums or interpreters
- Pass behavior around like any other value
3. Easy Composition
- Combine closures using standard Swift patterns
- Chain, map, filter, or wrap behaviors without boilerplate
- No need for complex reducer composition
4. Ad-hoc Polymorphism
- Different implementations can share the same closure signature
- Swap behavior at runtime without inheritance or protocols
- Dependency injection becomes trivial
5. Type Safety
- Compiler enforces closure signatures
- No runtime dispatch errors from missing enum cases
- Refactoring is safe and straightforward
Closures model behavior as data, but unlike enums, they execute directly without interpretation.
Capabilities: The Ultimate Abstraction
Because capabilities are pure data (closures), they unlock powerful development patterns:
Get Lazar Otasevic’s stories in your inbox
Join Medium for free to get updates from this writer.
Trivial Mocking
- Replace any capability with a test implementation
- No need to mock entire objects or stores
- Just pass different closures
Effortless Modularity
- Pass capabilities across module boundaries
- No need to expose internal state or reducers
- Modules depend only on closure signatures
Dependency Injection Made Simple
- Inject capabilities instead of entire environments
- No boilerplate, no service locators
- Compile-time safety for all dependencies
SwiftUI Previews
- Provide no-op or mock closures for previews
- No need to set up entire Store hierarchies
- Instant preview rendering
Testability
- Test logic in isolation by passing mock state
- No need to construct complex test fixtures
- Fast, deterministic unit tests
Capabilities are the ultimate abstraction: they’re data, but they represent behavior. This makes them perfect for composition, testing, and dependency injection.
Coupling: Why Capabilities Win
In software design, coupling refers to how tightly components depend on each other. Stevens, Myers, and Constantine identified several types of coupling in their foundational 1974 work, later expanded by Ian Cooper:
Types of Coupling (Worst to Best)
Common Coupling (Worst)
- Components share global state
- Changes to shared state affect all components
- Example: TCA’s
Storeas shared mutable state
Control Coupling
- One component controls the flow of another
- Example: TCA’s action enums dictate reducer behavior
Stamp Coupling
- Components depend on the structure of complex data types
- Changing the structure breaks dependent components
- Example: TCA’s
StateandActionenum hierarchies
Data Coupling (Best)
- Components depend only on the data they exchange
- Minimal, explicit dependencies
- Example: Capabilities — closures with simple signatures
Why Capabilities Achieve Data Coupling
Capabilities are the lowest form of coupling because:
- No shared state — each closure operates independently
- No control flow — closures don’t dictate how they’re called
- No structural dependency — just function signatures
- Explicit contracts — the closure type is the entire interface
TCA couples components through shared stores, action enums, and reducer hierarchies. Capabilities couple only through data (closure signatures).
What Is “Logic” Anyway?
Before proposing an alternative, we must define what logic actually means — because the term is often misused and misunderstood.
The Definition
From the dictionary:
Logic (n.): A system or set of principles underlying the arrangements of elements in a computer or electronic device so as to perform a specified task.
In software terms, logic is:
- Pure behavior — functions that operate on data
- Stateless — contains no internal state of its own
- Deterministic — same input always produces same output
- Composable — can be combined with other logic without side effects
- Disposable — can be created and destroyed on-demand without consequence
- An implementation detail — the “how,” not the “what”
Logic is NOT:
- A container for state
- A class that “manages” or “owns” data
- Something that needs to be retained or stored
- A “model” with methods attached
The MVVM Misconception
When people say “the ViewModel contains the logic,” they are incorrect. What they actually mean is:
“The ViewModel is a big ball of mud that mixes state, behavior, side effects, and lifecycle management into one object.”
A typical MVVM ViewModel looks like this:
class CounterViewModel: ObservableObject {
@Published var count: Int = 0 // State hidden inside
func increment() { count += 1 } // "Logic"
func decrement() { count -= 1 }
func reset() { count = 0 }
}What’s wrong here?
- State is hidden inside the ViewModel — you can’t see or test it independently
- Logic is coupled to state — you can’t reuse
incrementwithout dragging the entire ViewModel along - Not pure — methods mutate internal state, making them non-deterministic in isolation
- Not composable — you can’t combine ViewModels without complex delegation or observation patterns
This is not “logic.” This is object-oriented state management disguised as architecture.
True Logic: Stateless and Pure
Real logic looks like this:
struct CounterLogic {
@Binding var state: CounterState // Consumes state, doesn't own it
var increment: () -> Void { { state.count += 1 } }
var decrement: () -> Void { { state.count -= 1 } }
var reset: () -> Void { { state.count = 0 } }
}Why this is actual logic:
- Stateless — the struct owns no data, it’s disposable
- Pure — given the same state binding, behavior is deterministic
- Composable — you can create it on-demand, pass closures around, combine them freely
- Testable — you can test logic by passing in a mock binding
Logic is behavior without state. MVVM violates this by hiding state inside “logic” objects.
The Alternative: Decouple State from Logic
The key principle is decoupling state from behavior. In a SwiftUI-compatible, pure-functional architecture:
This aligns with the principles of Data-Oriented Programming (Sharvit, 2022):
- Separate data from behavior — state is pure data, logic is pure functions
- Data should be open — state is visible and accessible, not hidden inside objects
- Immutable data structures — state changes are explicit and controlled
1. State
- A simple value type (
struct) or observable reference type (class) - Hosted by the view (or test) — not owned by logic
- Pure data only — no behavior allowed inside
- Passed to logic as a binding or observable reference
struct CounterState {
var count: Int = 0
}// Or as observable class:
@Observable
class CounterState {
var count: Int = 0
// No methods, no behavior - pure data only
}2. Logic
- A stateless struct that consumes state
- Contains pure implementation details
- Exposes behavior as closures (capabilities)
struct CounterLogic {
@Binding var state: CounterState
var increment: () -> Void {
{ state.count += 1 }
}
var decrement: () -> Void {
{ state.count -= 1 }
}
var reset: () -> Void {
{ state.count = 0 }
}
}3. View (Host)
- Owns the state via
@State - Creates logic on-demand via computed property (stateless, disposable)
- Calls closures directly — no enums, no send()
struct CounterView: View {
@State private var state = CounterState()
var logic: CounterLogic {
CounterLogic(state: $state)
}
var body: some View {
VStack {
Text("\(state.count)")
Button("+", action: logic.increment)
Button("-", action: logic.decrement)
Button("Reset", action: logic.reset)
}
}
}The Capability Based Architecture
Using the capability based design from Scott Wlaschin we have the following components:
- State is separated from logic. It requires a host: View, View modifier, Dynamic property, unit test… or even a random class that you migrated from the stupid MVVM can be a host.
- Logic is pure behavior represented as a stateless and disposable struct. That’s why it is a computed property on the state-host. Contains immutable methods that are easy to test. Mutations are performed using closures (bindings are also closures). It is an implementation detail.
- Capability is an abstraction represented as pure data, it is composable, can contains any data, closures are also data. Mocking and ad-hoc polymorphism for free!
- UI consumes capability: recomposes data, calls closures, converts data to presentable form… no enums, no reducers, no send().
Note that Capabilities can be used in non-UI contexts perfectly well.
Comparison: TCA vs Capabilities
Behavior Representation
- TCA: Enum + Reducer
- Capabilities: Closures exposed by stateless logic
State Ownership
- TCA: Coupled to Reducer via Store
- Capabilities: Decoupled, hosted by view/test (struct or observable class)
Composition
- TCA: Nested enums, forwarding child actions
- Capabilities: Pass closures directly, compose functions
Boilerplate
- TCA: High (Store, ViewStore, send(), Effect, Environment)
- Capabilities: Minimal (State + Logic struct)
Extensibility
- TCA: Painful (update enums, switches, forwarding)
- Capabilities: Easy (add closure to logic)
SwiftUI Integration
- TCA: Indirect, requires send/reducer loop
- Capabilities: Direct, natural, SwiftUI-native
Modularity
- TCA: Weak, tightly coupled
- Capabilities: Strong, fully composable
Why This Works
- SwiftUI already treats views as functions of state.
- Actions as closures integrate natively with SwiftUI’s recomposition.
- Pure functions are testable in isolation.
- State and behavior are cleanly separated, reducing accidental complexity.
In short, this is exactly how SwiftUI and functional programming were designed to be used.
Conclusion
To be fair: TCA is miles better than MVVM, MVC, or any other MV* pattern. Why? Because those patterns assume there’s a “View” to manage — but there is no view in SwiftUI. SwiftUI is about data-flow programming: state flows down, events flow up. MV* patterns fight this model by introducing ViewModels, Controllers, and other unnecessary abstractions.
TCA at least recognizes that SwiftUI is declarative and state-driven. But it still makes critical mistakes:
TCA is, at its core, a glorified antipattern: it encodes behavior as enum data, couples state and logic, and generates enormous boilerplate. SwiftUI and functional programming principles provide a cleaner, simpler, and fully composable alternative:
- State = pure data (struct or observable class, no behavior)
- Logic = stateless struct (consumes state, exposes capabilities)
- Capabilities = closures (first-class functions, not enum cases)
No enums. No reducers. No OOP. No MVVM. No StateObject.
TCA solves a problem that does not exist — and in doing so, creates a series of antipatterns that functional programming and SwiftUI already solve elegantly.
References
- Hughes, John. Why Functional Programming Matters. 1989.
- Graham, Paul. On Lisp. 1993.
- Gabriel, Richard P. Patterns of Software: Tales from the Software Community. 1996.
- Fowler, Martin. Refactoring: Improving the Design of Existing Code. 2nd Edition, 2018.
- Krishnamurthi, Shriram. Programming Languages: Application and Interpretation. 2003.
- Wlaschin, Scott. Domain Modeling Made Functional. 2018.
- Sharvit, Yehonathan. Data-Oriented Programming. Manning, 2022.
- Stevens, Wayne P., Glenford J. Myers, and Larry L. Constantine. Structured Design. IBM Systems Journal, 1974.
- Yourdon, Edward, and Larry L. Constantine. Structured Design: Fundamentals of a Discipline of Computer Program and Systems Design. 1979.
- Cooper, Ian. Succeeding at Reactive Architecture. Build Stuff Conference. (Discusses types of coupling: common, control, data, and stamp coupling.)
End of Article








