§ · 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)
}

Hosting parent (orchestrator with no UI of its own)

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. Not quite headless in napkin's sense — a true headless napkin has no view controller at all; this one keeps a bare one around as a mounting point.

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 forwards the tap from the synchronous @MainActor context into an unstructured task at the caller's priority. The view's weak var listener keeps the view from retaining the interactor; the dispatched task holds the listener only while it runs, and cancellation — if you need it — is yours.

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. Spawn the fetch with task { } — work registered that way is cancelled automatically when the napkin deactivates. A bare await in the hook body is not, and it holds up attachChild besides.

ProfileInteractor.swift
func didBecomeActive() async {
    task {
        do {
            let profile = try await self.profileService.fetchProfile()
            await self.presenter.update(profile)
        } catch is CancellationError {
            // Napkin was deactivated mid-fetch — bail.
        } catch {
            await self.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
}