- napkin
- Tutorial: Building a Login Flow
API Collection
Tutorial: Building a Login Flow
A walkthrough of Napkin’s Rib House (Examples/RibHouse): two child napkins, a service held by the parent, dependencies declared from the top down.
Overview
This tutorial walks through the napkin example app, Examples/RibHouse. When you’re done you’ll have an end-to-end mental model of how the rings — Builder, Component, Interactor, Router, ViewController — work together for a parent napkin with two children, an injected service, and a real user-driven state transition.
What we’re building. An iOS app whose root is a headless LaunchNapkin that holds an AuthService. The Launch napkin starts by attaching a LoggedOutNapkin (one Login button). When the user taps it, the Launch interactor calls authService.login() — and that is all it does. Routing happens in the interactor’s auth gate: a lifecycle-bound subscription to authService.userStream() that swaps to a LoggedInNapkin when a User arrives and back when it becomes nil. Tapping Logout reverses the flow the same way: state changes, the gate reacts.
LaunchNapkin (headless container, holds AuthService)
├── LoggedOutNapkin (Login button → listener?.loggedOutDidTapLogin())
└── LoggedInNapkin (Logout button → listener?.loggedInDidTapLogout())
Only one child is attached at a time. The parent’s router enforces this by detaching the other before attaching.
Tip
Open the project alongside this tutorial — open Examples/RibHouse/RibHouse.xcodeproj — and keep each file pinned in a tab as you read. The tutorial follows the same file order as the napkin folders under Sources/.
Step 1: The data + service boundary
Start at the bottom: the data types and the service contract. These define the shape of what flows through everything else.
The User is a plain Sendable value:
struct User: Sendable, Equatable {
let name: String
let barbecueFoods: [String]
}
The AuthService is a Sendable protocol with three methods: login() and logout() change the auth state; userStream() is the state — an AsyncStream that replays the current user to every new subscriber and yields again on every change. That stream is the channel the LaunchNapkin’s auth gate (Step 3) subscribes to instead of routing from the button taps directly.
protocol AuthService: Sendable {
func login() async throws -> User
func logout() async throws
func userStream() async -> AsyncStream<User?>
}
The mock implementation is an actor, not a plain class, because it has to hold a subscriber list and broadcast to it:
// Mock implementation that hands back Smokey Joe with a tray of barbecue.
// Now an actor broadcaster — the README's CurrentValueSubject replacement:
// it owns the current user and replays it to every new subscriber, so the
// LaunchNapkin's gate routes from state instead of from taps. In a real
// app a concrete implementation would talk to a server; the LaunchNapkin
// only depends on the protocol, so the wiring doesn't change.
actor BarbecueAuthService: AuthService {
private(set) var currentUser: User?
private var subscribers: [UUID: AsyncStream<User?>.Continuation] = [:]
func login() async throws -> User {
let user = User(
name: "Smokey Joe",
barbecueFoods: [
"Brisket",
"Pulled Pork",
"St. Louis Ribs",
"Burnt Ends",
"Smoked Sausage",
]
)
setUser(user)
return user
}
func logout() async throws {
setUser(nil)
}
/// A fresh stream per subscriber: the current value immediately, then
/// every change.
func userStream() -> AsyncStream<User?> {
let (stream, continuation) = AsyncStream.makeStream(of: User?.self)
let id = UUID()
subscribers[id] = continuation
continuation.yield(currentUser)
continuation.onTermination = { [weak self] _ in
Task { await self?.removeSubscriber(id) }
}
return stream
}
// MARK: - Private
private func setUser(_ user: User?) {
currentUser = user
for continuation in subscribers.values {
continuation.yield(user)
}
}
private func removeSubscriber(_ id: UUID) {
subscribers[id]?.finish()
subscribers.removeValue(forKey: id)
}
}
Two things matter in userStream(): it replays the current value to a fresh subscriber (so a napkin that attaches after login still sees the right state), and setUser broadcasts on every change to every existing subscriber. That replay-then-broadcast pair is exactly what a lifecycle-scoped for await loop needs — no separate “give me the initial value” step.
Sendable here is the contract that this service can be passed across actor boundaries — important because the LaunchInteractor (an actor) will hold and call it.
Note
login() and logout() are async throws even though the mock implementation never throws — the signatures are part of the contract, not the mock; a real BackendAuthService would block on the network and surface errors, and the LaunchInteractor’s try await already handles both. userStream() is plain async (no throws) because a state stream can’t fail — subscribers just stop iterating when they’re done, rather than catching an error.
Step 2: The dependency root
Every napkin tree starts from a root component. In the example, that’s AppComponent in SceneDelegate.swift:
final class AppComponent: Component<EmptyDependency>, LaunchNapkinDependency, @unchecked Sendable {
let authService: AuthService
init(authService: AuthService = BarbecueAuthService()) {
self.authService = authService
super.init(dependency: EmptyComponent())
}
}
Two things to notice:
Important
The let authService is let, not var, and the value is initialized in init. This isn’t accidental — DI dependencies should be immutable for the lifetime of the napkin tree so that no one can swap services out from under live interactors.
Step 3: The LaunchNapkin (parent, holds the service)
A napkin has up to six files. Let’s walk through them in the order they get built.
Dependency
protocol LaunchNapkinDependency: Dependency {
var authService: AuthService { get }
}
The parent declares what it needs from above. The AppComponent satisfied this requirement in the previous step.
Component
final class LaunchNapkinComponent: Component<LaunchNapkinDependency>, @unchecked Sendable {
var authService: AuthService { dependency.authService }
}
extension LaunchNapkinComponent: LoggedOutNapkinDependency, LoggedInNapkinDependency {}
The Launch component:
Surfaces the parent-provided authService so the builder can read it.
Bridges to the children: child napkin Dependency protocols are declared empty (or with their own needs) — the parent’s component conforms to them so it can be passed as dependency: when constructing child builders.
Builder
final class LaunchNapkinBuilder: Builder<LaunchNapkinDependency>, LaunchNapkinBuildable, @unchecked Sendable {
@MainActor
func build(withListener listener: LaunchNapkinListener) async -> LaunchNapkinRouting {
let component = LaunchNapkinComponent(dependency: dependency)
let loggedOutBuilder = LoggedOutNapkinBuilder(dependency: component)
let loggedInBuilder = LoggedInNapkinBuilder(dependency: component)
let viewController = LaunchNapkinViewController()
let interactor = LaunchNapkinInteractor(authService: component.authService)
let router = LaunchNapkinRouter(
interactor: interactor,
viewController: viewController,
loggedOutBuilder: loggedOutBuilder,
loggedInBuilder: loggedInBuilder
)
await interactor.wire(router: router, listener: listener)
return router
}
}
The build sequence:
Construct the component from the parent’s dependency.
Construct the child napkin builders with component as their dependency.
Construct the view controller (a plain UIViewController — this napkin is headless).
Construct the interactor and inject the service via the component.
Wire the listener (the parent of LaunchNapkin, here AppListener).
Construct the router, inject the child builders, return it.
Interactor
The interactor holds the service and contains all the business logic.
final actor LaunchNapkinInteractor:
Interactable,
LoggedOutNapkinListener,
LoggedInNapkinListener
{
nonisolated let lifecycle = InteractorLifecycle()
nonisolated let authService: AuthService
weak var router: LaunchNapkinRouting?
weak var listener: LaunchNapkinListener?
init(authService: AuthService) {
self.authService = authService
}
func wire(router: LaunchNapkinRouting?, listener: LaunchNapkinListener?) {
self.router = router
self.listener = listener
}
func didBecomeActive() async {
// The auth gate: routing follows auth state, not taps. The stream
// replays the current value (nil at launch), which is what attaches
// the LoggedOut napkin. Bound to the active scope — cancelled
// automatically on willResignActive.
task {
for await user in await self.authService.userStream() {
if let user {
await self.router?.attachLoggedIn(user: user)
} else {
await self.router?.attachLoggedOut()
}
}
}
}
func willResignActive() async {}
// MARK: - LoggedOutNapkinListener
func loggedOutDidTapLogin() async {
do {
_ = try await authService.login()
// No routing here — the gate above reacts to the stream.
} catch {
// Login failed — stay on the logged-out screen. Real apps would
// surface an alert; we keep this demo silent.
}
}
// MARK: - LoggedInNapkinListener
func loggedInDidTapLogout() async {
// Routing happens via the stream, same as login.
try? await authService.logout()
}
}
Key points:
final actor — business logic is off the main actor.
Interactable (not PresentableInteractable) — Launch has no presenter because it has no view of its own.
nonisolated let authService — Sendable service, safe to expose nonisolated; the actor reads it directly without crossing its own boundary.
wire(router:listener:) — the builder calls this once, right after construction, to hand the interactor its router and listener. Nothing else may assign either property.
The auth gate lives in didBecomeActive(), not in the tap handlers. task { ... } spawns a Task bound to the active scope — napkin cancels it automatically when the interactor resigns active — and loops for await user in authService.userStream(), routing to attachLoggedIn or attachLoggedOut as the stream yields. The stream replays the current value immediately, so this same loop is what attaches LoggedOutNapkin at launch.
Neither loggedOutDidTapLogin() nor loggedInDidTapLogout() calls the router. They only change authService’s state (login() / logout()); the gate above is what reacts and routes. One place decides what to show, driven by state, instead of routing logic scattered across every intent handler.
Conforms to both child listener protocols — LoggedOutNapkinListener for the Login intent, LoggedInNapkinListener for the Logout intent. The router will hand interactor to each child’s builder as their listener.
Router
@MainActor
final class LaunchNapkinRouter:
LaunchRouter<LaunchNapkinInteractor, LaunchNapkinViewControllable>,
LaunchNapkinRouting
{
private let loggedOutBuilder: LoggedOutNapkinBuildable
private let loggedInBuilder: LoggedInNapkinBuildable
private var loggedOutRouter: LoggedOutNapkinRouting?
private var loggedInRouter: LoggedInNapkinRouting?
func attachLoggedOut() async {
await detachLoggedInIfNeeded()
guard loggedOutRouter == nil else { return }
let router = await loggedOutBuilder.build(withListener: interactor)
loggedOutRouter = router
await attachChild(router)
viewController.embed(router.viewControllable)
}
func attachLoggedIn(user: User) async {
await detachLoggedOutIfNeeded()
guard loggedInRouter == nil else { return }
let router = await loggedInBuilder.build(withListener: interactor, user: user)
loggedInRouter = router
await attachChild(router)
viewController.embed(router.viewControllable)
}
private func detachLoggedOutIfNeeded() async {
guard let router = loggedOutRouter else { return }
loggedOutRouter = nil
viewController.detach(router.viewControllable)
await detachChild(router)
}
// detachLoggedInIfNeeded mirrors the above.
}
The pattern to internalize:
Each attach method removes the other child first. This ensures only one is ever active and the framework’s lifecycle invariants hold.
pingBuilder.build(withListener: interactor) — the router passes its own interactor as the listener to the child. That’s how the listener chain is hooked up.
attachChild(...) activates the child’s lifecycle (calls its didBecomeActive). detachChild(...) reverses it.
viewController.embed(...) is a method on the LaunchNapkin’s ViewControllable protocol — the router calls it to add the child’s view to the parent’s UIKit hierarchy.
ViewController
LaunchNapkin’s view controller is a plain UIViewController (not a hosting controller), because it doesn’t render its own SwiftUI — it embeds children.
@MainActor
final class LaunchNapkinViewController: UIViewController, LaunchNapkinViewControllable {
func embed(_ child: ViewControllable) {
let childVC = child.uiviewController
addChild(childVC)
childVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(childVC.view)
NSLayoutConstraint.activate([
childVC.view.topAnchor.constraint(equalTo: view.topAnchor),
childVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
childVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
childVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
childVC.didMove(toParent: self)
}
func detach(_ child: ViewControllable) {
let childVC = child.uiviewController
childVC.willMove(toParent: nil)
childVC.view.removeFromSuperview()
childVC.removeFromParent()
}
}
Standard UIKit child-view-controller plumbing.
Step 4: A child napkin (LoggedOutNapkin)
The children follow the same six-file shape as the parent but in miniature. Here’s what we’re building visually — paper-cream background, editorial kicker, serif-italic hero, hairline rule, ink button:

Tip
This screenshot is the exact reference image used by the snapshot test in Examples/RibHouse/SnapshotTests/LoggedOutNapkinViewSnapshotTests.swift. Any change to the view that affects the rendering breaks the test and fails CI.
Dependency, Component, Builder
protocol LoggedOutNapkinDependency: Dependency {}
final class LoggedOutNapkinComponent: Component<LoggedOutNapkinDependency>, @unchecked Sendable {}
final class LoggedOutNapkinBuilder: Builder<LoggedOutNapkinDependency>, LoggedOutNapkinBuildable, @unchecked Sendable {
@MainActor
func build(withListener listener: LoggedOutNapkinListener) async -> LoggedOutNapkinRouting {
let viewController = LoggedOutNapkinViewController()
let interactor = LoggedOutNapkinInteractor(presenter: viewController)
let router = LoggedOutNapkinRouter(interactor: interactor, viewController: viewController)
await interactor.wire(router: router, listener: listener)
return router
}
}
Empty dependency because LoggedOutNapkin needs nothing from above (no service of its own). The parent’s component still satisfies the protocol — LaunchNapkinComponent: LoggedOutNapkinDependency is an empty conformance.
Interactor
protocol LoggedOutNapkinListener: AnyObject, Sendable {
func loggedOutDidTapLogin() async
}
final actor LoggedOutNapkinInteractor: PresentableInteractable, LoggedOutNapkinPresentableListener {
nonisolated let lifecycle = InteractorLifecycle()
nonisolated let presenter: LoggedOutNapkinPresentable
weak var listener: LoggedOutNapkinListener?
// PresentableListener: view → this actor
func didTapLogin() async {
// Listener: this actor → parent (Launch)
await listener?.loggedOutDidTapLogin()
}
}
Two listener types live in this file:
LoggedOutNapkinPresentableListener — the view talks down to the interactor through this.
LoggedOutNapkinListener — the interactor talks up to the parent through this. The parent (LaunchInteractor) conforms to it.
That’s the napkin listener pattern. Each napkin’s interactor forwards user intent upward via the listener?.xxxDidYyy() method.
Tip
This split keeps the view’s vocabulary (didTapLogin()) separate from the parent’s vocabulary (loggedOutDidTapLogin()). The interactor sits between them, translating low-level taps into high-level intents.
View
struct LoggedOutNapkinView: View {
weak var listener: LoggedOutNapkinPresentableListener?
var body: some View {
VStack(spacing: 28) {
Text("Step inside the *smokehouse*.")
Button("Login") {
dispatch { [listener] in await listener?.didTapLogin() }
}
}
}
}
The dispatch { ... } helper is napkin’s bridge from @MainActor SwiftUI action closures into the interactor’s actor — it spawns a Task and forwards the call. See Cross-Isolation Patterns for why this pattern is preferred over inline Task { ... }.
Step 5: The other child napkin (LoggedInNapkin)
LoggedInNapkin is the same shape, with two notable differences. The visual flip: dark paper background, the user’s name in serif italic, the foods rendered as a numbered spec-list (mirroring the homepage’s 01 · / 02 · /… pattern), and a ghost outline LOGOUT button.

Dependency declares the AuthService
protocol LoggedInNapkinDependency: Dependency {
var authService: AuthService { get }
}
final class LoggedInNapkinComponent: Component<LoggedInNapkinDependency>, @unchecked Sendable {
var authService: AuthService { dependency.authService }
}
Even though LoggedInNapkin doesn’t currently call the service (only LaunchInteractor does), the dependency is declared so:
The component contract documents what’s available.
Future versions of the napkin can read the service without re-wiring.
The parent (LaunchNapkinComponent) already exposes authService, so its existing extension LaunchNapkinComponent: LoggedInNapkinDependency {} automatically satisfies the new requirement.
User flows all the way through to the router
protocol LoggedInNapkinBuildable: Buildable {
@MainActor func build(
withListener listener: LoggedInNapkinListener,
user: User
) async -> LoggedInNapkinRouting
}
final class LoggedInNapkinBuilder: ... {
@MainActor
func build(
withListener listener: LoggedInNapkinListener,
user: User
) async -> LoggedInNapkinRouting {
let viewController = LoggedInNapkinViewController(user: user)
let interactor = LoggedInNapkinInteractor(presenter: viewController, user: user)
let router = LoggedInNapkinRouter(
interactor: interactor,
viewController: viewController,
user: user
)
await interactor.wire(router: router, listener: listener)
return router
}
}
The user parameter threads the full chain: LaunchInteractor → LaunchRouter.attachLoggedIn(user:) → loggedInBuilder.build(withListener:, user:) → LoggedInNapkinViewController(user:) / LoggedInNapkinInteractor(... , user:) / LoggedInNapkinRouter(... , user:).
The data flows along the same path as the routing call. The router holds it as let user: User; the view receives it via UIHostingController(rootView: LoggedInNapkinView(user: user)).
Important
The user object only exists for the lifetime of one LoggedIn napkin instance. When the user logs out, the router detaches (and releases) that napkin entirely. On the next login the router builds a new LoggedIn napkin with a fresh user. No state survives the swap.
Step 6: The SceneDelegate
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var launchRouter: LaunchNapkinRouting?
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)
}
}
}
The bootstrap is three steps:
Build the AppComponent (the dependency root).
Build the root router via LaunchNapkinBuilder.
Call launch(from: window), which installs the root view controller, activates the interactor, and starts the tree.
Step 7: Snapshot testing the views
The example app uses Point-Free’s swift-snapshot-testing to pin each napkin view’s appearance. A regression in any view — wrong palette token, dropped spacing, broken layout — flips the test red.
The package is declared in Examples/RibHouse/project.yml:
packages:
swift-snapshot-testing:
url: https://github.com/pointfreeco/swift-snapshot-testing
from: "1.18.0"
And the RibHouseSnapshotTests target depends on the SnapshotTesting product:
RibHouseSnapshotTests:
type: bundle.unit-test
platform: iOS
sources:
- path: SnapshotTests
dependencies:
- target: RibHouse
- package: swift-snapshot-testing
product: SnapshotTesting
Each test file mounts the SwiftUI view in a UIHostingController and hands it to assertSnapshot:
import SnapshotTesting
import SwiftUI
import XCTest
@testable import RibHouse
@MainActor
final class LoggedOutNapkinViewSnapshotTests: XCTestCase {
func testLoggedOutNapkinView() {
let view = LoggedOutNapkinView()
let vc = UIHostingController(rootView: view)
assertSnapshot(of: vc, as: .image(on: .iPhone13Pro))
}
}
Note
First run records — assertSnapshot writes a PNG into __Snapshots__/<TestClass>/<testMethod>.1.png next to the test file and reports the test as failed with the message “No reference was found on disk. Automatically recorded snapshot.” Re-run the same test and it asserts against the freshly-recorded reference; that’s the green state.
The recorded reference PNGs are committed to source control (under Examples/RibHouse/SnapshotTests/__Snapshots__/), so the test compares the runtime render against a known-good image rather than depending on the developer to record locally.
Important
Snapshot stability requires a fixed device. The example pins to .iPhone13Pro because Point-Free’s library ships preset device configurations for it (matching iPhone 17 Pro’s logical resolution closely enough for our purposes). Running the same test against .iPhoneX or .iPadPro12_9 would produce a different image — every device needs its own recorded reference.
If you change a view intentionally, re-record with one of:
Then commit the regenerated PNGs alongside the view change.
Wrapping up
The full data flow on a login tap:
Login button tap
→ dispatch { await listener?.didTapLogin() } (PresentableListener)
→ listener?.loggedOutDidTapLogin() (LoggedOutNapkinListener)
→ LaunchInteractor.loggedOutDidTapLogin() (Launch conforms to the listener)
→ try await authService.login() (state changes…)
→ userStream() yields the User (…the gate hears it…)
→ router?.attachLoggedIn(user: user) (…and routes)
What that demonstrates:
State-driven routing. Routing isn’t triggered by a tap; it’s triggered by authService.userStream() yielding a new value. The tap only changes state (login() / logout()); a single lifecycle-bound loop in didBecomeActive() is the only place that calls attachLoggedIn or attachLoggedOut.
Composition over inheritance. No class FooInteractor: BaseInteractor. The actor conforms to Interactable; lifecycle is delegated to InteractorLifecycle.
Explicit isolation crossings. View @MainActor → actor Interactor via dispatch. Actor → router @MainActor via await. Actor → service Sendable via plain call.
DI through the dependency chain. The parent’s component conforms to its children’s dependency protocols. Services injected at the top reach the leaves without anyone hand-rolling a singleton.
Listener pattern for upward communication. Children never import or reference their parent — they communicate intent through a <Self>NapkinListener protocol that the parent’s interactor implements.
For the running code: Examples/RibHouse. Open the project file directly — xcodegen is no longer required.
Topics
The deeper why
Building from scratch