Composition
Composition is a core principle of TCA. It allows you to build complex features from smaller, focused features that can be developed, tested, and reasoned about independently. TCA provides several tools for composing features together.
Why Composition?
Modularity Break large features into smaller, manageable pieces
Reusability Share features across different parts of your app
Testability Test features in isolation without dependencies
Team Collaboration Different team members can work on separate features
Composing State
Compose larger state from smaller state types:
@Reducer
struct AppFeature {
@ObservableState
struct State {
var profile: Profile.State
var settings: Settings.State
var search: Search.State
}
enum Action {
case profile (Profile.Action)
case settings (Settings.Action)
case search (Search.Action)
}
}
Child Features
Each child feature is independent:
@Reducer
struct Profile {
@ObservableState
struct State {
var name: String = ""
var bio: String = ""
}
enum Action {
case nameChanged ( String )
case bioChanged ( String )
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case let . nameChanged (name) :
state. name = name
return . none
case let . bioChanged (bio) :
state. bio = bio
return . none
}
}
}
}
Composing Reducers
Use the Scope reducer to embed child reducers:
var body: some Reducer<State, Action> {
Scope ( state : \. profile , action : \. profile ) {
Profile ()
}
Scope ( state : \. settings , action : \. settings ) {
Settings ()
}
Scope ( state : \. search , action : \. search ) {
Search ()
}
Reduce { state, action in
// Parent logic
return . none
}
}
Source: Scope.swift:1-225
Child reducers run before parent logic. This ensures children process their actions before the parent potentially changes the state structure.
Optional Child State
Compose features that may not always be present:
@ObservableState
struct State {
@Presents var destination: Destination.State ?
}
enum Action {
case destination (PresentationAction<Destination.Action>)
case showDestination
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case . showDestination :
state. destination = Destination. State ()
return . none
case . destination :
return . none
}
}
. ifLet (\.$destination, action : \. destination ) {
Destination ()
}
}
The @Presents Macro
The @Presents macro simplifies working with optional child state:
@ObservableState
struct State {
@Presents var alert: AlertState<Action.Alert> ?
@Presents var sheet: Sheet.State ?
}
Source: Macros.swift:136-143
Collection Composition
Compose features for collections of child state:
import IdentifiedCollections
@Reducer
struct TodoList {
@ObservableState
struct State {
var todos: IdentifiedArrayOf<Todo.State> = []
}
enum Action {
case todos (IdentifiedActionOf<Todo>)
case addTodo
case deleteTodo ( id : Todo.State.ID)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case . addTodo :
state. todos . append (Todo. State ( id : UUID ()))
return . none
case let . deleteTodo (id) :
state. todos . remove ( id : id)
return . none
case . todos :
return . none
}
}
. forEach (\. todos , action : \. todos ) {
Todo ()
}
}
}
IdentifiedArray
Use IdentifiedArray for collections:
import IdentifiedCollections
@ObservableState
struct Todo : Identifiable {
let id: UUID
var description: String
var isComplete: Bool
}
// Instead of:
var todos: [Todo] = []
// Use:
var todos: IdentifiedArrayOf<Todo> = []
IdentifiedArray provides O(1) lookups and ensures unique elements, making it perfect for managing collections in TCA.
Enum-Based Composition
Use enums to model mutually exclusive child states:
@Reducer
enum Destination {
case addItem (AddItem)
case editItem (EditItem)
case settings (Settings)
}
@Reducer
struct Parent {
@ObservableState
struct State {
@Presents var destination: Destination.State ?
}
enum Action {
case destination (PresentationAction<Destination.Action>)
case showAddItem
case showEditItem (Item)
case showSettings
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case . showAddItem :
state. destination = . addItem (AddItem. State ())
return . none
case let . showEditItem (item) :
state. destination = . editItem (EditItem. State ( item : item))
return . none
case . showSettings :
state. destination = . settings (Settings. State ())
return . none
case . destination :
return . none
}
}
. ifLet (\.$destination, action : \. destination )
}
}
Parent-Child Communication
Children can communicate with parents through delegate actions:
@Reducer
struct Child {
@ObservableState
struct State {
var text: String = ""
}
enum Action {
case saveButtonTapped
case cancelButtonTapped
case delegate (Delegate)
enum Delegate {
case didSave ( String )
case didCancel
}
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case . saveButtonTapped :
return . send (. delegate (. didSave (state. text )))
case . cancelButtonTapped :
return . send (. delegate (. didCancel ))
case . delegate :
return . none
}
}
}
}
Parent Handling Delegate Actions
@Reducer
struct Parent {
@ObservableState
struct State {
@Presents var child: Child .State ?
var savedItems: [ String ] = []
}
enum Action {
case child (PresentationAction< Child .Action>)
case showChild
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case . showChild :
state. child = Child . State ()
return . none
case let . child (. presented (. delegate (. didSave (text)))) :
state. savedItems . append (text)
state. child = nil
return . none
case . child (. presented (. delegate (. didCancel ))) :
state. child = nil
return . none
case . child :
return . none
}
}
. ifLet (\.$child, action : \. child ) {
Child ()
}
}
}
Delegate actions are a clean way for children to notify parents without creating tight coupling.
Combining Multiple Reducers
Use CombineReducers to group reducers:
var body: some Reducer<State, Action> {
CombineReducers {
AnalyticsReducer ()
LoggingReducer ()
CoreReducer ()
}
. _printChanges ()
}
Source: CombineReducers.swift:1-45
Scoping Stores
Scope stores to pass to child views:
struct AppView : View {
let store: StoreOf<AppFeature>
var body: some View {
TabView {
ProfileView (
store : store. scope ( state : \. profile , action : \. profile )
)
. tabItem { Label ( "Profile" , systemImage : "person" ) }
SettingsView (
store : store. scope ( state : \. settings , action : \. settings )
)
. tabItem { Label ( "Settings" , systemImage : "gear" ) }
}
}
}
Source: Store.swift:220-268
Benefits of Scoping
Type Safety : Child views can’t access parent state
Performance : Views only re-render when their scope changes
Modularity : Child features can be extracted to separate modules
Testing : Features can be tested independently
Navigation Patterns
Stack-Based Navigation
For navigation stacks:
import SwiftUI
@ObservableState
struct State {
var path = StackState < Path. State > ()
}
enum Action {
case path (StackActionOf<Path>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
// Core logic
}
. forEach (\. path , action : \. path )
}
struct ContentView : View {
@Bindable var store: StoreOf<Feature>
var body: some View {
NavigationStack ( path : $store. scope ( state : \. path , action : \. path )) {
// Root view
}
}
}
Tree-Based Navigation
For sheets, popovers, and alerts:
@ObservableState
struct State {
@Presents var destination: Destination.State ?
}
enum Action {
case destination (PresentationAction<Destination.Action>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
// Core logic
}
. ifLet (\.$destination, action : \. destination ) {
Destination ()
}
}
Testing Composed Features
Test features in isolation:
@MainActor
final class ChildTests : XCTestCase {
func testChild () async {
let store = TestStore ( initialState : Child . State ()) {
Child ()
}
await store. send (. saveButtonTapped )
await store. receive (\. delegate . didSave )
}
}
Test parent-child integration:
@MainActor
final class ParentTests : XCTestCase {
func testParentChildIntegration () async {
let store = TestStore ( initialState : Parent. State ()) {
Parent ()
}
await store. send (. showChild ) {
$0 . child = Child . State ()
}
await store. send (. child (. presented (. saveButtonTapped )))
await store. receive (\. child . presented . delegate . didSave ) {
$0 . savedItems = [ "test" ]
$0 . child = nil
}
}
}
Best Practices
Start Small
Begin with small, focused features before composing them: // ✅ Good: Focused feature
@Reducer
struct Counter {
// Simple state and actions
}
// ❌ Avoid: Monolithic feature
@Reducer
struct App {
// Everything in one place
}
Compose Hierarchically
Build features in layers: App
├── Tab1
│ ├── List
│ └── Detail
└── Tab2
├── Profile
└── Settings
Use Delegate Actions
Children should communicate with parents through delegate actions, not by directly modifying parent state.
Scope Appropriately
Scope stores to give views only what they need: // ✅ Good: Scoped store
ChildView (
store : store. scope ( state : \. child , action : \. child )
)
// ❌ Avoid: Passing full store
ChildView ( store : store)
Keep Features Independent
Features should work without knowledge of their parents: // ✅ Good: Self-contained
@Reducer
struct Profile {
// No parent dependencies
}
// ❌ Avoid: Coupled to parent
@Reducer
struct Profile {
var parentState: ParentFeature.State // Don't do this
}
Common Patterns
Shared State
Use the @Shared property wrapper for state shared across features:
@ObservableState
struct State {
@Shared (. appStorage ( "isLoggedIn" )) var isLoggedIn = false
}
Cross-Feature Communication
Use effects to communicate between siblings:
case . featureA (. delegate (. didComplete )) :
return . send (. featureB (. start ))
Progressive Disclosure
Load child features lazily:
case . showChild :
guard state.child == nil else { return . none }
state. child = Child . State ()
return . none