home / skills / kaakati / rails-enterprise-dev / coordinator-pattern

coordinator-pattern skill

/plugins/reactree-ios-dev/skills/coordinator-pattern

This skill guides iOS navigation architecture using Coordinator pattern, clarifying when to use, how to design hierarchies, and flow completion strategies.

npx playbooks add skill kaakati/rails-enterprise-dev --skill coordinator-pattern

Review the files below or copy the command above to add this skill to your agents.

Files (3)
SKILL.md
11.1 KB
---
name: coordinator-pattern
description: "Expert Coordinator pattern decisions for iOS/tvOS: when coordinators add value vs overkill, parent-child coordinator hierarchy design, SwiftUI vs UIKit coordinator differences, and flow completion handling. Use when designing navigation architecture, implementing multi-step flows, or decoupling views from navigation. Trigger keywords: Coordinator, navigation, flow, parent coordinator, child coordinator, deep link, routing, navigation hierarchy, flow completion"
version: "3.0.0"
---

# Coordinator Pattern — Expert Decisions

Expert decision frameworks for Coordinator pattern choices. Claude knows the pattern — this skill provides judgment calls for when coordinators add value and how to structure hierarchies.

---

## Decision Trees

### Do You Need Coordinators?

```
How many navigation flows does your app have?
├─ 1-2 simple flows
│  └─ Skip coordinators
│     NavigationStack + simple Router is enough
│
├─ 3-5 distinct flows
│  └─ Consider coordinators IF:
│     • Flows have complex branching
│     • Deep linking is required
│     • Flows need to share navigation logic
│
└─ 6+ flows or multi-team development
   └─ Coordinators recommended
      • Clear ownership boundaries
      • Parallel development possible
      • Testable navigation logic
```

**The trap**: Adding coordinators to simple apps. If your app is 5 screens with linear flow, coordinators add complexity without benefit.

### Coordinator Hierarchy Design

```
Does this flow need to manage sub-flows?
├─ NO (leaf coordinator)
│  └─ Simple coordinator
│     Owns NavigationPath, creates views
│
└─ YES (has sub-flows)
   └─ Parent coordinator
      Manages childCoordinators array
      Delegates to child for sub-flows
```

### SwiftUI vs UIKit Coordinator

```
Which UI framework?
├─ SwiftUI
│  └─ Coordinator as ObservableObject
│     • Owns NavigationPath
│     • @ViewBuilder for destinations
│     • Pass via EnvironmentObject or explicit injection
│
└─ UIKit
   └─ Coordinator owns UINavigationController
      • Creates and pushes ViewControllers
      • Uses delegation for flow completion
      • Manages childCoordinators manually
```

### Flow Completion Strategy

```
How does a flow end?
├─ Success (user completed task)
│  └─ Delegate method with result
│     coordinator.didCompleteLogin(user: user)
│
├─ Cancellation (user backed out)
│  └─ Delegate method without result
│     coordinator.didCancelLogin()
│
└─ Automatic (flow naturally ends)
   └─ Parent removes child automatically
      No explicit completion needed
```

---

## NEVER Do

### Child Coordinator Lifecycle

**NEVER** forget to remove child coordinators:
```swift
// ❌ Memory leak — child coordinator retained forever
final class ParentCoordinator {
    var childCoordinators: [Coordinator] = []

    func startLoginFlow() {
        let loginCoordinator = LoginCoordinator()
        childCoordinators.append(loginCoordinator)
        loginCoordinator.start()
        // Never removed! Leaks.
    }
}

// ✅ Remove child on flow completion
final class ParentCoordinator: LoginCoordinatorDelegate {
    var childCoordinators: [Coordinator] = []

    func startLoginFlow() {
        let loginCoordinator = LoginCoordinator()
        loginCoordinator.delegate = self
        childCoordinators.append(loginCoordinator)
        loginCoordinator.start()
    }

    func loginCoordinatorDidFinish(_ coordinator: LoginCoordinator) {
        childCoordinators.removeAll { $0 === coordinator }
    }
}
```

**NEVER** use strong parent references:
```swift
// ❌ Retain cycle — coordinator never deallocates
final class ChildCoordinator {
    var parent: ParentCoordinator  // Strong reference!
}

// ✅ Weak parent or delegate
final class ChildCoordinator {
    weak var delegate: ChildCoordinatorDelegate?
    // OR
    weak var parent: ParentCoordinator?
}
```

### Coordinator Responsibilities

**NEVER** put business logic in coordinators:
```swift
// ❌ Coordinator doing business logic
final class CheckoutCoordinator {
    func completeOrder() async {
        // Business logic leaked into coordinator!
        let total = cart.items.reduce(0) { $0 + $1.price }
        let tax = total * 0.08
        try await paymentService.charge(total + tax)
    }
}

// ✅ Coordinator orchestrates, ViewModel/UseCase handles logic
final class CheckoutCoordinator {
    func showCheckout() {
        let viewModel = CheckoutViewModel(
            cartService: container.cartService,
            paymentService: container.paymentService
        )
        // ViewModel handles business logic
    }
}
```

**NEVER** let views know about coordinator hierarchy:
```swift
// ❌ View knows about parent coordinator
struct LoginView: View {
    let coordinator: LoginCoordinator

    var body: some View {
        Button("Done") {
            coordinator.parent?.childDidFinish(coordinator)  // Wrong!
        }
    }
}

// ✅ View only knows its immediate coordinator
struct LoginView: View {
    let coordinator: LoginCoordinator

    var body: some View {
        Button("Done") {
            coordinator.completeLogin()  // Coordinator handles delegation
        }
    }
}
```

### SwiftUI-Specific

**NEVER** create coordinators as @StateObject in child views:
```swift
// ❌ New coordinator created on every parent rebuild
struct ParentView: View {
    var body: some View {
        ChildView()  // Child creates its own coordinator
    }
}

struct ChildView: View {
    @StateObject var coordinator = ChildCoordinator()  // Wrong!
}

// ✅ Parent creates and owns coordinator
struct ParentView: View {
    @StateObject var childCoordinator = ChildCoordinator()

    var body: some View {
        ChildView(coordinator: childCoordinator)
    }
}
```

**NEVER** use NavigationLink directly when using coordinators:
```swift
// ❌ Bypasses coordinator — navigation untracked
struct UserListView: View {
    var body: some View {
        NavigationLink("User") {
            UserDetailView()  // Coordinator doesn't know about this!
        }
    }
}

// ✅ Delegate navigation to coordinator
struct UserListView: View {
    @ObservedObject var coordinator: UsersCoordinator

    var body: some View {
        Button("User") {
            coordinator.showUserDetail(userId: "123")
        }
    }
}
```

---

## Essential Patterns

### SwiftUI Coordinator Protocol

```swift
@MainActor
protocol Coordinator: ObservableObject {
    associatedtype Route: Hashable
    var path: NavigationPath { get set }

    func start() -> AnyView
    func navigate(to route: Route)
    func pop()
    func popToRoot()
}

extension Coordinator {
    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path = NavigationPath()
    }
}
```

### Parent-Child Coordinator

```swift
@MainActor
protocol ParentCoordinatorProtocol: AnyObject {
    var childCoordinators: [any Coordinator] { get set }
    func addChild(_ coordinator: any Coordinator)
    func removeChild(_ coordinator: any Coordinator)
}

extension ParentCoordinatorProtocol {
    func addChild(_ coordinator: any Coordinator) {
        childCoordinators.append(coordinator)
    }

    func removeChild(_ coordinator: any Coordinator) {
        childCoordinators.removeAll { $0 === coordinator as AnyObject }
    }
}

// Tab coordinator managing child coordinators
@MainActor
final class TabCoordinator: ParentCoordinatorProtocol, ObservableObject {
    var childCoordinators: [any Coordinator] = []

    lazy var homeCoordinator: HomeCoordinator = {
        let coordinator = HomeCoordinator()
        coordinator.parent = self
        addChild(coordinator)
        return coordinator
    }()

    lazy var profileCoordinator: ProfileCoordinator = {
        let coordinator = ProfileCoordinator()
        coordinator.parent = self
        addChild(coordinator)
        return coordinator
    }()
}
```

### Flow Completion with Result

```swift
protocol LoginCoordinatorDelegate: AnyObject {
    func loginCoordinator(_ coordinator: LoginCoordinator, didFinishWith result: LoginResult)
}

enum LoginResult {
    case success(User)
    case cancelled
}

@MainActor
final class LoginCoordinator: ObservableObject {
    weak var delegate: LoginCoordinatorDelegate?
    @Published var path = NavigationPath()

    enum Route: Hashable {
        case credentials
        case forgotPassword
        case twoFactor(email: String)
    }

    func completeLogin(user: User) {
        delegate?.loginCoordinator(self, didFinishWith: .success(user))
    }

    func cancel() {
        delegate?.loginCoordinator(self, didFinishWith: .cancelled)
    }
}
```

### Deep Link Integration

```swift
@MainActor
final class AppCoordinator: ObservableObject, ParentCoordinatorProtocol {
    var childCoordinators: [any Coordinator] = []
    @Published var path = NavigationPath()

    func handle(deepLink: DeepLink) {
        // Reset to known state
        popToRoot()
        childCoordinators.forEach { removeChild($0) }

        // Navigate to deep link destination
        switch deepLink {
        case .user(let id):
            navigate(to: .userList)
            navigate(to: .userDetail(userId: id))

        case .checkout:
            let checkoutCoordinator = CheckoutCoordinator()
            checkoutCoordinator.delegate = self
            addChild(checkoutCoordinator)
            // Present checkout flow

        case .settings(let section):
            navigate(to: .settings)
            if let section = section {
                navigate(to: .settingsSection(section))
            }
        }
    }
}
```

---

## Quick Reference

### Coordinator Checklist

- [ ] Coordinator owns NavigationPath (SwiftUI) or UINavigationController (UIKit)
- [ ] Parent-child references are weak
- [ ] Child coordinators removed on flow completion
- [ ] Views don't know about coordinator hierarchy
- [ ] Business logic stays in ViewModels/UseCases
- [ ] Deep links handled at appropriate coordinator level

### When to Use Coordinators

| Scenario | Use Coordinator? |
|----------|------------------|
| Simple 3-5 screen app | No — simple Router |
| Multiple independent flows | Yes |
| Deep linking required | Likely yes |
| Multi-step wizard flows | Yes |
| Cross-tab navigation | Yes |
| A/B testing navigation | Yes |
| Team-based feature ownership | Yes |

### Red Flags

| Smell | Problem | Fix |
|-------|---------|-----|
| childCoordinators grows forever | Memory leak | Remove on completion |
| Strong parent reference | Retain cycle | Use weak or delegate |
| Business logic in coordinator | Wrong layer | Move to ViewModel/UseCase |
| View creates NavigationLink | Bypasses coordinator | Delegate to coordinator |
| @StateObject coordinator in child | Recreated on rebuild | Parent owns coordinator |
| Coordinator creates its own views | Can't inject dependencies | Use ViewFactory |

### Coordinator vs Router

| Aspect | Coordinator | Router |
|--------|-------------|--------|
| Complexity | Higher | Lower |
| Hierarchy support | Yes (parent-child) | No |
| Flow isolation | Strong | Weak |
| Testing | Excellent | Good |
| Learning curve | Steep | Gentle |
| Best for | Large apps, teams | Small-medium apps |

Overview

This skill offers practical, opinionated guidance for applying the Coordinator pattern in iOS and tvOS projects. It helps you decide when coordinators add value, how to structure parent-child hierarchies, and how to handle flow completion, SwiftUI vs UIKit differences, and deep links. Use it to simplify navigation ownership, avoid common pitfalls, and design testable navigation flows.

How this skill works

The skill inspects your navigation needs and flow complexity to recommend whether to adopt coordinators or a lighter router. It outlines hierarchy patterns: leaf coordinators for single flows and parent coordinators managing childCoordinators for sub-flows. It also gives concrete lifecycle rules (weak parent references, removing children on completion), framework-specific implementations (ObservableObject + NavigationPath for SwiftUI, UINavigationController ownership for UIKit), and flow completion strategies (delegate with result, cancellation, or automatic removal).

When to use it

  • Multiple distinct flows (6+ or multi-team) where ownership and parallel work matter
  • Multi-step wizards, checkout flows, or branching navigation that needs isolation
  • Deep linking that must rebuild a known navigation state
  • Cross-tab navigation or flows that must be presented modally from several places
  • When you need testable navigation logic and clear completion/result handling

Best practices

  • Only introduce coordinators when navigation complexity or team scale warrants them; avoid for tiny linear apps
  • Keep coordinators thin: orchestrate navigation, delegate business logic to ViewModels/UseCases
  • Manage child lifecycle explicitly: add childCoordinators and remove them on completion to avoid leaks
  • Use weak parent references or delegate patterns to prevent retain cycles
  • In SwiftUI, have the parent own @StateObject coordinators and expose them via injection/EnvironmentObject; avoid NavigationLink bypasses
  • Handle flow completion with delegates or explicit result enums (success/cancel) rather than implicit assumptions

Example use cases

  • App with many independent flows and multiple teams where coordinators define ownership boundaries
  • Login or onboarding flows that return a success/cancel result to the parent coordinator
  • Deep link handling at the AppCoordinator level that resets state and composes child coordinators
  • A tab-based app where a TabCoordinator lazily creates and manages Home and Profile child coordinators
  • Multi-step checkout where each step is a route on a coordinator NavigationPath

FAQ

Do I always need a coordinator for SwiftUI apps?

No. For small apps with 1–2 simple flows, NavigationStack and a simple router are sufficient. Use coordinators when flows branch, require deep links, or need shared navigation logic.

How should I remove child coordinators to avoid leaks?

Remove children in the parent’s completion callback or delegate method. Keep childCoordinators as a mutable array and remove by identity when the child signals it finished.