Recommended pattern for flat enum dispatch in routing reducers (avoiding ___chkstk_darwin stack overflow) #3914
Replies: 1 comment
-
|
in case it is useful context, I asked claude for a general summary of our architecture/decisions. I have reviewed the below to make sure I'm not purely sharing AI slop. Also sharing in case you or others find useful. TCA Navigation Architecture: Custom Tabs, Sheets & Routing ReducersA practical reference for building a production iOS app with The Composable Architecture — covering custom tab bars, dynamic-height sheets, manual routing reducers, and the tradeoffs behind each decision. OverviewOur app is an app with 5 tabs, 20+ push destinations, 40+ sheet screens, and fullscreen covers. The navigation architecture is built on TCA with several custom layers to work around SwiftUI and TCA limitations we hit in production. Key departures from vanilla TCA navigation:
1. Custom Tab BarWe use a custom floating capsule-shaped tab bar ( Why not SwiftUI TabView?Design reasons:
Technical reasons:
How tabs are managedEach tab owns an independent @Reducer
struct TabCoordinator {
@Reducer
enum Root {
case explore(ExploreFeature)
case lobby(LobbyFeature)
case chat(ChatsFeature)
case feed(FeedFeature)
case profile(ProfileFeature)
}
@ObservableState
struct State: Equatable {
var root: Root.State
var path = StackState<Path.State>()
@Presents var cover: CoverDestination.State?
}
}
struct TabFeature {
@ObservableState
struct State: Equatable {
var selectedTab: Tab = .explore
var exploreCoordinator: Indirect<TabCoordinator.State>
var lobbyCoordinator: Indirect<TabCoordinator.State>
var chatCoordinator: Indirect<TabCoordinator.State>
var feedCoordinator: Indirect<TabCoordinator.State>
var profileCoordinator: Indirect<TabCoordinator.State>
}
}Key property: tabs are fully independent. Each tab has its own Shared Path enumThe @Reducer
enum Path {
case chat(ChatsFeature)
case chatRoom(ChatRoomFeature)
case clan(ClanFeature)
case profile(ProfileFeature)
case settings(SettingsFeature)
case chartTrading(ChartTradingFeature)
// ... 20+ cases
}Why one enum instead of per-tab enums? Most destinations are reachable from multiple tabs. A profile can be opened from Explore, Chat, Feed, or another profile. A clan can be opened from a profile, the leaderboard, or chat. Maintaining 5 separate path enums would mean duplicating most cases across all of them. 2. Custom Sheet SystemWe use a custom ZStack-based sheet system ( Why not SwiftUI .sheet()?The primary reason is dynamic content sizing. SwiftUI's SwiftUI's
Additional benefits of our custom sheet:
How it works
@Reducer
struct TabSheetCoordinator {
@Reducer
enum Screen {
case memberActionsSheet(MemberActionsSheetFeature)
case changeDisplayName(ChangeDisplayNameFeature)
case closePosition(ClosePositionFeature)
case createClan(CreateClanFeature)
case withdrawSelection(WithdrawSelectionFeature)
// ... 40+ cases
}
@ObservableState
struct State: Equatable {
var path = StackState<Screen.State>()
var isPopping = false
let id = UUID() // Identity change on dismiss→re-present
}
}Presentation flow:
Multi-step flows (deposit, clan creation, withdrawals) push screens onto the stack: // Inside TabSheetCoordinator reducer:
case .createClan(.delegate(.presentClanImageSelector(state))):
state.path.append(.clanImageSelector(state))
return .noneReturning results: Sheet delegate actions bubble up through Why @presents is still used on the sheet slotWe use
3. Manual Routing Reducers (Stack Overflow Fix)The problem
We discovered this in production. The app shipped with This is a known issue: #3063, #2321, #1046. The fixWe keep // @Reducer enum generates State, Action, CasePathable — but NOT the reducer body.
@Reducer
enum Path {
case chat(ChatsFeature)
case profile(ProfileFeature)
case settings(SettingsFeature)
// ... 20+ cases
}
// Manual flat dispatch — O(1) stack depth regardless of case count.
struct PathReducer: Reducer {
typealias State = Path.State
typealias Action = Path.Action
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch (state, action) {
case (.chat(var cs), .chat(let ca)):
let effect = ChatsFeature()._reduce(into: &cs, action: ca).map(Action.chat)
state = .chat(cs)
return effect
case (.profile(var cs), .profile(let ca)):
let effect = ProfileFeature()._reduce(into: &cs, action: ca).map(Action.profile)
state = .profile(cs)
return effect
// ... each case follows the same pattern
default:
return .none
}
}
}Then in the parent coordinator's body, we use the manual reducer instead of the auto-generated one: var body: some ReducerOf<Self> {
Scope(state: \.root, action: \.root) {
RootReducer() // NOT Root() — manual flat dispatch
}
.forEach(\.path, action: \.path) {
PathReducer() // NOT Path() — manual flat dispatch
}
}Where we use this
The
|
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Recommended pattern for flat enum dispatch in routing reducers (avoiding ___chkstk_darwin stack overflow)
We have routing reducers with 20-30+ destination cases (navigation stacks, sheet coordinators). Using
@Reducer enumgenerates deeply nested_IfCaseLetReducer<_IfCaseLetReducer<...>>chains that cause___chkstk_darwin/EXC_BAD_ACCESSstack overflows on physical devices in Release builds. The simulator is unaffected (8MB stack vs 1MB on device). The Swift optimizer inlines the@inlinableTCA functions in Release, which balloons the stack frame size at each nesting level.This is the same class of issue described in #3063, #2321, and #1046.
Our workaround
We bypass
@Reducer enum's auto-generated body entirely with a manualswitchreducer. This produces zero generic nesting — a flat function with O(1) stack depth regardless of case count:We use this pattern in three places: navigation stack paths (~23 cases), sheet coordinators (~40 cases), and fullscreen cover destinations (~5 cases). It's been stable in production.
The deprecation concern
With #3857 deprecating
reduce(into:action:)in 1.25, we've migrated to_reduce(into:action:)— the same non-deprecated internal dispatch method used byScope,IfCaseLetReducer, andReducerBuilder._Sequence. This silences the warning and is functionally identical.However, the deprecation message says TCA 2.0 will remove the ability to invoke
reduceentirely, and_reduceis an underscored API with no stability guarantees.Questions
_reduceis removed or made internal in 2.0, what's the intended pattern for manually dispatching to child reducers? Will enum scoping or aReducerBuilderchange flatten the generic depth so@Reducer enumis safe for 20-30+ cases?@_optimize(none)on the body (which TCA already uses on_reduce) but that only helps at the call site, not the generated body type._ReduceArraysimilar to whatReducerBuilderalready uses forbuildArray?Beta Was this translation helpful? Give feedback.
All reactions