Sitemap
Lazar Otasevic

Lazar Otasevic

SwiftUI / iOS / ex Game / C++ developer

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!

11 min readDec 10, 2025

--

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.

Press enter or click to view image in full size
If you complicate it good enough, it becomes cool.

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:

  1. 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).
  2. 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).
  3. Boilerplate explodes. Every new action requires updating multiple switches, forwarding child actions, and wrapping/unwrapping enums.
  4. 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:

  • @State for local view state
  • @Observable for reference-type models
  • DynamicProperty protocol for custom state containers
  • Automatic dependency tracking and view invalidation

TCA ignores all of this. Instead, it:

  • Wraps state in a Store object (low in the hierarchy)
  • Forces you to use ViewStore to 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:

  1. Fights SwiftUI’s declarative model — SwiftUI expects state to live in the view hierarchy, not buried in a Store
  2. Adds unnecessary indirection — WithViewStore is a wrapper around a wrapper
  3. Breaks native dependency tracking — SwiftUI can’t optimize rendering because state is opaque
  4. Duplicates observation logic — TCA reimplements what @Observable already 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:

  • @Environment for 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.

Press enter or click to view image in full size
Apple’s take on SWIFTUI DATA FLOW

SwiftUI already offers unidirectional data flow. But it was too simple and not cool. TCA provides something much cooler:

Press enter or click to view image in full size
RUBE GOLDBERS TAKE ON SWIFTUI DATA FLOW

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 Store as 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 State and Action enum 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:

  1. No shared state — each closure operates independently
  2. No control flow — closures don’t dictate how they’re called
  3. No structural dependency — just function signatures
  4. 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?

  1. State is hidden inside the ViewModel — you can’t see or test it independently
  2. Logic is coupled to state — you can’t reuse increment without dragging the entire ViewModel along
  3. Not pure — methods mutate internal state, making them non-deterministic in isolation
  4. 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:

  1. Stateless — the struct owns no data, it’s disposable
  2. Pure — given the same state binding, behavior is deterministic
  3. Composable — you can create it on-demand, pass closures around, combine them freely
  4. 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().
DIAGRAM

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

  1. Hughes, John. Why Functional Programming Matters. 1989.
  2. Graham, Paul. On Lisp. 1993.
  3. Gabriel, Richard P. Patterns of Software: Tales from the Software Community. 1996.
  4. Fowler, Martin. Refactoring: Improving the Design of Existing Code. 2nd Edition, 2018.
  5. Krishnamurthi, Shriram. Programming Languages: Application and Interpretation. 2003.
  6. Wlaschin, Scott. Domain Modeling Made Functional. 2018.
  7. Sharvit, Yehonathan. Data-Oriented Programming. Manning, 2022.
  8. Stevens, Wayne P., Glenford J. Myers, and Larry L. Constantine. Structured Design. IBM Systems Journal, 1974.
  9. Yourdon, Edward, and Larry L. Constantine. Structured Design: Fundamentals of a Discipline of Computer Program and Systems Design. 1979.
  10. Cooper, Ian. Succeeding at Reactive Architecture. Build Stuff Conference. (Discusses types of coupling: common, control, data, and stamp coupling.)

End of Article

--

Lazar Otasevic
Lazar Otasevic

Written by Lazar Otasevic

SwiftUI / iOS / ex Game / C++ developer