§ · Cookbook

Recipes.

Short, named patterns for common napkin shapes. Copy, paste, rename.

Attach a child napkin

The parent's router builds the child, attaches it (which activates its lifecycle), and adds the child's view to the parent's hierarchy.

ParentRouter.swift
func routeToProfile() async {
    let router = await profileBuilder.build(withListener: interactor)
    self.profileRouter = router
    await attachChild(router)
    viewController.embed(router.viewControllable)
}

Inject a service through the dependency tree

The feature declares what it needs; the parent component satisfies it. Compile-checked, no runtime container.

HomeBuilder.swift
protocol HomeDependency: Dependency {
    var authService: AuthService { get }
}

final class HomeComponent: Component<HomeDependency>, @unchecked Sendable {
    var authService: AuthService { dependency.authService }
}

// The parent's component conforms to HomeDependency:
extension AppComponent: HomeDependency {}

Swap two child napkins on tap

Two children, only one attached at a time. Each attachX tears down the other first.

SwapRouter.swift
func attachA() async {
    await detachBIfNeeded()
    guard aRouter == nil else { return }
    let router = await aBuilder.build(withListener: interactor)
    aRouter = router
    await attachChild(router)
    viewController.embed(router.viewControllable)
}

private func detachBIfNeeded() async {
    guard let router = bRouter else { return }
    bRouter = nil
    viewController.detach(router.viewControllable)
    await detachChild(router)
}

Headless parent (orchestrator with no view)

For napkins that route between children without rendering their own UI. The view controller is a plain UIViewController with embed/detach for the active child.

ParentViewController.swift
@MainActor
final class ParentViewController: UIViewController, ParentViewControllable {

    func embed(_ child: ViewControllable) {
        let vc = child.uiviewController
        addChild(vc)
        vc.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(vc.view)
        NSLayoutConstraint.activate([
            vc.view.topAnchor.constraint(equalTo: view.topAnchor),
            vc.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            vc.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            vc.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        vc.didMove(toParent: self)
    }

    func detach(_ child: ViewControllable) {
        let vc = child.uiviewController
        vc.willMove(toParent: nil)
        vc.view.removeFromSuperview()
        vc.removeFromParent()
    }
}

Forward a tap from SwiftUI to the interactor

Use the dispatch helper, not inline Task { } — it captures the listener weakly and handles cancellation.

HomeView.swift
struct HomeView: View {
    weak var listener: HomePresentableListener?

    var body: some View {
        Button("Refresh") {
            dispatch { [listener] in await listener?.didTapRefresh() }
        }
    }
}

Send an intent up the tree

Children never import their parent. They call a method on a <Self>NapkinListener the parent's interactor conforms to.

CounterInteractor.swift
protocol CounterNapkinListener: AnyObject, Sendable {
    func counterDidFinish() async
}

final actor CounterInteractor: ..., CounterPresentableListener {
    weak var listener: CounterNapkinListener?

    func didTapDone() async {
        await listener?.counterDidFinish()
    }
}

Fetch data when the napkin becomes active

Lifecycle hooks are just async methods on the actor. Cancellation is automatic when the napkin deactivates.

ProfileInteractor.swift
func didBecomeActive() async {
    do {
        let profile = try await profileService.fetchProfile()
        await presenter.update(profile)
    } catch is CancellationError {
        // Napkin was deactivated mid-fetch — bail.
    } catch {
        await presenter.update(.error(error))
    }
}

Test an interactor

Mock the protocols, call methods directly. No simulator, no XCTestExpectation.

CartInteractorTests.swift
@MainActor
final class CartInteractorTests: XCTestCase {
    func testAddItem_updatesPresenter() async {
        let presenter = MockCartPresentable()
        let sut = CartInteractor(presenter: presenter)

        await sut.didTapAddItem(.brisket)

        XCTAssertEqual(presenter.updateCalls.count, 1)
        XCTAssertEqual(presenter.updateCalls.last?.items, [.brisket])
    }
}

Bootstrap the root napkin from SceneDelegate

Build the app component, build the launch router, call launch(from:). Three lines of meaningful code.

SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: ...) {
    guard let windowScene = scene as? UIWindowScene else { return }
    let window = UIWindow(windowScene: windowScene)
    self.window = window

    Task { @MainActor in
        let builder = LaunchNapkinBuilder(dependency: AppComponent())
        let router = await builder.build(withListener: AppListener())
        self.launchRouter = router
        await router.launch(from: window)
    }
}

Pass data through to a child router

Builder takes data as a parameter on build(...). The router holds it as let user: User; the interactor and view receive it via the builder.

LoggedInBuilder.swift
protocol LoggedInBuildable: Buildable {
    @MainActor func build(
        withListener listener: LoggedInListener,
        user: User
    ) async -> LoggedInRouting
}

@MainActor
func build(withListener listener: LoggedInListener, user: User) async -> LoggedInRouting {
    let viewController = LoggedInViewController(user: user)
    let interactor = LoggedInInteractor(presenter: viewController, user: user)
    await interactor.set(listener: listener)
    let router = LoggedInRouter(interactor: interactor, viewController: viewController, user: user)
    await interactor.set(router: router)
    return router
}