🍫 CocoaPatterns — iOS & Mac Development Since 2009 RSS
🍫 CocoaPatterns
Because someone has to write this stuff down
"We ship apps. Sometimes they even work."

Enum-Driven Development with ReactiveEnum: Every Value Is a Signal, Every Signal Is an Enum

What if your entire application state was a single enum with 847 cases? What if each case emitted a reactive signal? What if I've already built this?

TW
Thaddeus Wren
@twren_fp · CocoaPatterns

I've spent the last six months at the intersection of two ideas that most people think are unrelated: enums and reactive programming. Most developers see enums as simple value types — a list of options, a state machine, a way to avoid string constants. And most developers see ReactiveCocoa as a way to handle asynchronous events with signals and subscribers.

What if they're the same thing?

What if every application state is an enum case, every state transition is a signal, and your entire app is a single reactive enum that emits its own state changes? What if the enum is the architecture?

I've built this. It's called ReactiveEnum, and it's either the future of iOS development or a cautionary tale. Possibly both.

"An enum with enough cases can model any system. A reactive enum with enough cases can model any system that also notifies you when it changes. This is either profound or tautological and I've stopped trying to tell the difference."

The Core Idea

In traditional iOS, app state is scattered across dozens of objects. The user's auth state is in one singleton. The current view controller is another. Network reachability is a third. You end up with a web of KVO observations, delegate callbacks, and notification center subscriptions trying to keep everything in sync.

ReactiveEnum replaces all of this with a single enum:

typedef NS_ENUM(NSInteger, AppState) {
    AppStateLaunching,
    AppStateUnauthenticated,
    AppStateAuthenticating,
    AppStateAuthenticated,
    AppStateAuthenticatedProfileLoading,
    AppStateAuthenticatedProfileLoaded,
    AppStateAuthenticatedProfileLoadedSettingsOpen,
    AppStateAuthenticatedProfileLoadedSettingsOpenNotifications,
    AppStateAuthenticatedProfileLoadedSettingsOpenNotificationsEnabled,
    AppStateAuthenticatedProfileLoadedSettingsOpenNotificationsDisabled,
    AppStateAuthenticatedProfileLoadedSettingsOpenDarkMode,
    AppStateAuthenticatedProfileLoadedSettingsOpenDarkModeOn,
    AppStateAuthenticatedProfileLoadedSettingsOpenDarkModeOff,
    // ... 834 more cases ...
    AppStateError,
    AppStateErrorRecoverable,
    AppStateErrorUnrecoverable,
    AppStateErrorUnrecoverableButWeTriedAnyway,
};

Every possible state the app can be in has its own enum case. There is no ambiguity. There is no "what state are we in?" The enum tells you. It tells you with 847 cases, but it tells you.

💡 Key Principle

Exhaustive switch statements are the enforcement mechanism of ReactiveEnum. The compiler won't let you handle 846 out of 847 cases. You must handle every state. This is either the greatest type safety feature in iOS development or a form of punishment, depending on your perspective.

The Reactive Layer

Each state transition emits a RACSignal. When the app moves from AppStateUnauthenticated to AppStateAuthenticating, a signal fires. Every part of the app that cares about this transition subscribes to it. The login screen listens for AppStateAuthenticated. The profile screen listens for AppStateAuthenticatedProfileLoaded. The settings screen listens for any state with "Settings" in the name.

// Subscribe to state changes
[[ReactiveEnum sharedInstance].stateSignal
    filter:^BOOL(NSNumber *state) {
        // Match any "Authenticated" state (cases 3-844)
        return state.integerValue >= 3 
            && state.integerValue <= 844;
    }]
    subscribeNext:^(NSNumber *state) {
        // We're authenticated! Which sub-state? Check the number.
        // I hope you memorized the enum.
    }];

The filter block checks if the state integer is between 3 and 844 because those are the "Authenticated" states. Yes, we're filtering by magic numbers in a system designed to eliminate magic numbers. The irony is not lost on me. It's lost on the compiler, which is fine with it.

The State Explosion

The mathematical reality of ReactiveEnum is: every boolean in your app doubles the number of enum cases. Dark mode on/off? Double. Notifications enabled/disabled? Double again. Onboarding complete/incomplete? Double once more. Ten booleans means 1,024 enum cases. Twenty booleans means 1,048,576 cases.

We currently model 10 independent boolean states as nested enum paths, giving us approximately 1,024 leaf states. We don't model them as a flat enum (that would be insane — 1,024 top-level cases). Instead, we use nested enums that compose hierarchically. This limits any single switch statement to about 20 cases. The nesting depth, however, can reach 14 levels.

⚠️ The Checkout Flow

The checkout flow has a state path that is 14 levels deep: .authenticated(.shopping(.cartFilled(.checkoutStarted(.addressEntered(.shippingSelected(.paymentEntered(.cardValidated(.threeDSecure(.challenged(.completed(.receiptShown(.surveyPrompted(.done)))))))))))))). The switch statement for this path is 3,200 lines. Adding a "gift wrapping" option would add 14 new cases and require updating every switch statement in the checkout module. We have declined to add gift wrapping.

Adding a New Feature

When a product manager asks "can we add a feature toggle?", the engineering answer used to be "sure, it's a boolean." In ReactiveEnum, a boolean doubles the state space. Adding a feature toggle means:

  1. Add new cases to the enum hierarchy (double the leaf states in the affected subtree)
  2. Update every switch statement that handles states in that subtree
  3. Add signal subscriptions for the new state transitions
  4. Write tests for every new case (we have one test per case; adding 40 cases means 40 tests)
  5. Update the state diagram, which is now a poster-sized printout on the wall that we've run out of room for

Average time to add a feature toggle: 3 days. In a normal architecture: 20 minutes. But our toggle is type-safe. You cannot access a feature in a state where the toggle is off. The compiler physically prevents it. Is that worth 3 days? We've chosen to believe yes.

Results

  • Total enum cases (all levels): ~1,024
  • Maximum nesting depth: 14 levels
  • Longest switch statement: 3,200 lines
  • Compiler errors when adding one case: average 47 (one per switch statement that doesn't handle it)
  • Runtime state bugs found in production: 0 (genuinely — the compiler catches everything)
  • Time to add a boolean: 3 days
  • Time to understand the full state tree: 2 weeks for new engineers, ongoing for existing ones
  • Engineers who think this is a good idea: 1 (me)
  • Production crashes caused by invalid state: 0
  • Production crashes caused by the state system itself: also 0

Zero runtime state bugs. Zero invalid state crashes. ReactiveEnum is the most type-safe architecture I've ever built. It's also the most verbose, the most time-consuming to extend, and the most likely to make a new hire cry during onboarding. But zero state bugs. Zero. That number sustains me through the 3,200-line switch statements. Mostly.

4 Comments — Powered by Disqus
BK
bradkow_ios · 4 days ago
Thaddeus, the AppState enum has a case called .loading(.authenticated(.profileLoaded(.settingsOpen(.notificationsEnabled(.dark_mode(true)))))). I had to scroll right for forty seconds.
TW
twren_fpAuthor · 4 days ago
@bradkow_ios That's a six-level deep state. The app has 14 levels. You haven't even seen the checkout flow.
MO
mel_ios_eng · 3 days ago
The switch statement for this enum is 3,200 lines long and every case returns a different RACSignal. Xcode autocomplete just shows '...' when I type 'AppState.' It's given up.
DP
dpurcell_dev · 2 days ago
I added a new case to the enum and got 847 compiler errors because the exhaustive switch statement in 23 files needed updating. 'Exhaustive' is doing a lot of work in that sentence.