napkin is a fork of Uber's RIBs rebuilt on Swift 6.2 native concurrency. It structures iOS and macOS applications as a tree of modular units using the Router-Interactor-Builder pattern, with business logic running off the main actor and routing/presentation pinned to it.
napkin is the Router-Interactor-Builder pattern rebuilt on Swift 6 concurrency. Honest comparisons with the alternatives (facts as of July 2026):
| napkin | RIBs-iOS | TCA | |
|---|---|---|---|
| Shape | Tree of screen/flow units | Tree of screen/flow units | State/Action/Reducer/Store |
| Reactive layer | None — actors, AsyncStream, async/await |
RxSwift 6.9 + RxRelay | Effects + Point-Free deps |
| Leak safety | Compile-time isolation | Runtime LeakDetector |
N/A (value types) |
| Min platform | iOS 26 / macOS 26 | iOS 15 | iOS 16 |
| License | Apache-2.0 | Apache-2.0 | MIT |
Longer, cited write-ups: napkin vs RIBs · napkin vs TCA · napkin vs VIPER · when to use napkin
- How napkin compares
- Supported Platforms
- Installation
- Architecture Overview
- Concurrency Model
- Core Components
- Routing & Navigation
- Streaming State Down the Tree
- Launching the App
- SwiftUI Integration
- Testing
- Tooling
- Versioning
- Contributing
- Author
- License
- iOS 26.0+
- macOS 26.0+
These are deliberate support targets, not the hard compiler minimum. napkin's sources type-check down to iOS 18 / macOS 15 (bounded by Mutex from the Synchronization module). The project intentionally tracks only the current OS generation so the actor model and isolated deinit-based teardown (SE-0371, Swift 6.2) run on a single current Swift runtime instead of a back-deployment matrix.
Add napkin via Swift Package Manager:
- In Xcode, navigate to File > Add Package Dependencies...
- Paste the repository URL:
https://github.com/WikipediaBrown/napkin.git - Click Add Package.
Xcode 26 — Default Actor Isolation. Xcode 26's App template sets the Default Actor Isolation build setting to
MainActor. napkin'sBuilderandComponentare deliberatelynonisolated(DI plumbing, off any actor), so in aMainActor-default module aBuilder/Componentsubclass will fail to compile with "Main actor-isolated initializer 'init(dependency:)' has different actor isolation from nonisolated overridden declaration." Mark eachBuilder/Componentsubclassnonisolated(the bundled Xcode templates already do — see the snippets below), or set the target's Default Actor Isolation tononisolated. Routers and view controllers stay@MainActor; interactors stayactors. Full explanation in Getting Started.
napkin structures your app as a tree of units called "napkins." Each napkin encapsulates a feature and consists of:
flowchart LR
subgraph napkin[" "]
direction LR
B([Builder]):::builder --> R([Router]):::core
B --> I([Interactor]):::core
B -.-> P([Presenter]):::optional
R --> I
R --> C([Child Routers]):::children
P -.-> V([View]):::optional
end
classDef core fill:#4a90d9,stroke:#2c5aa0,color:#fff
classDef builder fill:#50c878,stroke:#3a9a5c,color:#fff
classDef optional fill:#f5f5f5,stroke:#999,color:#666,stroke-dasharray: 5 5
classDef children fill:#ffb347,stroke:#cc8a2e,color:#fff
| Component | Required | Role |
|---|---|---|
| Builder | Yes | Constructs the napkin, wires dependencies |
| Component | Yes | Provides dependencies to this napkin and its children |
| Interactor | Yes | Business logic, state management, lifecycle |
| Router | Yes | Manages the napkin tree (attach/detach children) |
| Presenter | No | Transforms business data into view-friendly formats |
| View | No | UIKit view controller or SwiftUI hosting controller |
Data flows down the tree. Events flow up via listener protocols. For values that keep changing after a child is built, see Streaming State Down the Tree.
napkin uses Swift 6.2 native concurrency. Business logic in the Interactor runs off the main actor by construction; routing and presentation run on the main actor.
| Layer | Isolation |
|---|---|
Interactable (protocol) + per-feature final actor |
actor |
InteractorLifecycle (helper) |
final class @unchecked Sendable (Mutex-protected) |
Router / ViewableRouter / LaunchRouter |
@MainActor |
Presenter (@Observable) |
@MainActor |
ViewControllable |
@MainActor |
Builder / Component |
non-isolated, Sendable |
Crossings between layers are explicit await points:
- Interactor → Router:
await router?.routeToProfile() - Interactor → Presenter:
await presenter.presentUser(user) - View → Interactor (events):
dispatch { await listener?.didTapLogout() }
Combine has been removed. View-state changes flow through @Observable properties on the Presenter; lifecycle-bound subscriptions use task { for await … } over a service's AsyncStream or an Observations sequence — recipes in Streaming State Down the Tree.
Swift actors do not support inheritance (SE-0306). Rather than fall back to @MainActor open class (which would pin business logic to the main actor) or a custom @globalActor (which would serialize all interactors on one executor), napkin uses protocol composition: each feature's interactor is its own final actor conforming to Interactable. The InteractorLifecycle class — the only @unchecked Sendable type in the framework — owns the mutex-protected lifecycle state and its concurrency contract. Default implementations of activate / deactivate / task(_:) / isActive / isActiveStream come from a protocol extension that delegates to lifecycle.
Uber's RIBs-iOS PR #49 unifies the framework on @MainActor (Interactor included). napkin deliberately keeps the Interactor off the main actor so business logic is not pinned to the main thread. The cost is await at every cross-layer call; the benefit is enforced clean-architecture isolation.
Both frameworks agree the view-facing seam belongs on @MainActor. napkin's base Presentable protocol is annotated @MainActor, so every feature's presentable (and any var listener it requires) inherits that isolation. The Swift 6 conformance error a RIB-shaped listener seam otherwise hits — "Main actor-isolated property 'listener' cannot be used to satisfy nonisolated protocol requirement" (RIBs-iOS #43) — is therefore structurally impossible in napkin: the requirement and its @MainActor view-controller witness are always in the same isolation domain. The child-to-parent listener is a separate seam (an actor-isolated weak var behind a Sendable async protocol) and never had the problem. Full write-up: The Swift 6 @MainActor listener-conformance error.
The Builder constructs a napkin and wires its dependencies. It receives a Dependency from its parent and returns a Router. Builder is Sendable and non-isolated.
When the napkin has a view, mark build() as @MainActor async — @MainActor because UIViewController initialization requires the main actor; async because wiring the actor-based interactor (e.g. setting the listener and router) requires await:
protocol HomeDependency: Dependency {
var userService: UserServiceProtocol { get }
}
protocol HomeBuildable: Buildable {
@MainActor func build(withListener listener: HomeListener) async -> HomeRouting
}
nonisolated final class HomeBuilder: Builder<HomeDependency>, HomeBuildable {
@MainActor
func build(withListener listener: HomeListener) async -> HomeRouting {
let component = HomeComponent(dependency: dependency)
let viewController = HomeViewController()
let interactor = HomeInteractor(
presenter: viewController,
userService: component.userService
)
let router = HomeRouter(interactor: interactor, viewController: viewController)
await interactor.wire(router: router, listener: listener)
return router
}
}For napkins without views, build() does not need @MainActor:
protocol AnalyticsBuildable: Buildable {
func build(withListener listener: AnalyticsListener) async -> AnalyticsRouting
}A Dependency protocol declares what a napkin requires from its parent. A Component provides those dependencies and can create new ones for its children.
Use shared {} to create a single instance per component scope. Without shared, a new instance is created on each access. The shared() method is thread-safe.
protocol HomeDependency: Dependency {
var analyticsService: AnalyticsServiceProtocol { get }
var userSession: UserSession { get }
}
nonisolated final class HomeComponent: Component<HomeDependency> {
// Passed through from parent
var analyticsService: AnalyticsServiceProtocol {
dependency.analyticsService
}
// Created once, shared within this scope
var userService: UserServiceProtocol {
shared { UserService(session: dependency.userSession) }
}
// New instance each time
var viewModel: HomeViewModel {
HomeViewModel(service: userService)
}
}The root napkin uses EmptyDependency:
nonisolated final class AppComponent: Component<EmptyDependency>, HomeDependency {
var analyticsService: AnalyticsServiceProtocol {
shared { AnalyticsService() }
}
var userSession: UserSession {
shared { UserSession() }
}
}The Interactor contains all business logic. It is a final actor conforming to Interactable (or PresentableInteractable when paired with a view), and holds an InteractorLifecycle helper. Lifecycle is driven by its parent router: didBecomeActive() when attached, willResignActive() when detached. Both are async.
Interactors communicate:
- Up to parent napkins via
weak var listener(aSendableprotocol the parent implements) - Down to navigation via
weak var router(an@MainActorrouting protocol the router implements)
Listener and routing methods are async because they cross isolation boundaries.
protocol HomeListener: AnyObject, Sendable {
func homeDidRequestLogout() async
}
@MainActor
protocol HomeRouting: ViewableRouting, Sendable {
func routeToProfile() async
}
protocol HomePresentable: Presentable, Sendable {
@MainActor var listener: HomePresentableListener? { get set }
func presentUser(_ user: User) async
}
final actor HomeInteractor: PresentableInteractable, HomePresentableListener {
nonisolated let lifecycle = InteractorLifecycle()
nonisolated let presenter: HomePresentable
weak var router: HomeRouting?
weak var listener: HomeListener?
private let userService: UserServiceProtocol
init(presenter: HomePresentable, userService: UserServiceProtocol) {
self.presenter = presenter
self.userService = userService
}
func wire(router: HomeRouting?, listener: HomeListener?) {
self.router = router
self.listener = listener
}
func didBecomeActive() async {
// Lifecycle-bound subscription: cancelled automatically on willResignActive.
task {
for await user in await self.userService.userStream() {
await self.presenter.presentUser(user)
}
}
}
func willResignActive() async {
// Tasks started via `task { }` are cancelled automatically here.
}
// MARK: - HomePresentableListener
func didTapProfile() async {
await router?.routeToProfile()
}
func didTapLogout() async {
await listener?.homeDidRequestLogout()
}
}didBecomeActive / willResignActive are protocol default-implementation methods, so there is no override and no super call. Subscriptions started with task { } on the lifecycle are cancelled automatically when the interactor deactivates. The service side of userStream() — and every other Combine-replacement recipe — is in Streaming State Down the Tree.
Use PresentableInteractable when the interactor communicates with a view through a presentable protocol. Use plain Interactable for napkins without views.
An interactor's parent router drives it between two states. You override two callbacks; the lifecycle handles the transitions, the bound tasks, and teardown.
stateDiagram-v2
direction LR
[*] --> Inactive
Inactive --> Active: activate() → didBecomeActive()
Active --> Inactive: deactivate() → willResignActive() → bound tasks cancelled
activate()/deactivate()are called by the parent router onattachChild/detachChild— you never call them yourself, and both are idempotent.didBecomeActive()is where you start observation;willResignActive()is where you flush state or notify the listener.- Work spawned with
task { }is bound to the active scope and cancelled automatically on deactivate — napkin's replacement fordisposeOnDeactivatefrom Uber's RIBs. No manual teardown. - A read-only view of the state is available through
isActiveand theisActiveStreamAsyncStream<Bool>.
The full contract — the non-recursive Mutex guarding lifecycle state, the exact deactivate() ordering, and the deinit backstop that makes a runtime leak detector unnecessary — lives in the lifecycle guide and the InteractorLifecycle reference.
The Router is @MainActor, manages the napkin tree, owns the interactor, maintains a list of children, and coordinates navigation.
attachChild(_:)async— adds a child router, activates its interactor, and loads itdetachChild(_:)async— deactivates the child's interactor and removes itdidLoad()async open— called once when the router is first loaded; attach permanent children here
Use Router<InteractorType> for napkins without views. Use ViewableRouter<InteractorType, ViewControllerType> when the napkin has a view controller.
@MainActor
protocol HomeRouting: ViewableRouting, Sendable {
func routeToProfile() async
func routeBackFromProfile() async
}
@MainActor
final class HomeRouter: ViewableRouter<HomeInteractor, HomeViewControllable>,
HomeRouting {
private let profileBuilder: ProfileBuildable
private var profileRouter: ProfileRouting?
init(interactor: HomeInteractor,
viewController: HomeViewControllable,
profileBuilder: ProfileBuildable) {
self.profileBuilder = profileBuilder
super.init(interactor: interactor, viewController: viewController)
}
func routeToProfile() async {
guard profileRouter == nil else { return }
let router = await profileBuilder.build(withListener: interactor)
profileRouter = router
await attachChild(router)
viewController.uiviewController.present(
router.viewControllable.uiviewController,
animated: true
)
}
func routeBackFromProfile() async {
guard let router = profileRouter else { return }
profileRouter = nil
viewController.uiviewController.dismiss(animated: true)
await detachChild(router)
}
}Because the router is already on the main actor, there are no Task { @MainActor in } hops. attachChild / detachChild are async and serialize with the interactor's actor when activating / deactivating. The pattern is:
- Build the child (
async @MainActorfor view-owning napkins) await attachChild(router)— activates the interactor on its own actor, loads the router on the main actor- Present — manipulates the view hierarchy directly on
@MainActor
For detaching, the order is reversed: dismiss the view, then await detachChild(router).
The Presenter transforms business data into view-friendly formats. It sits between the interactor and the view controller. Presenter is @MainActor and @Observable, so SwiftUI views can read its stored properties directly. Re-annotate subclasses with @Observable so their own stored properties are tracked too:
protocol HomePresentable: Presentable, Sendable {
func presentUser(_ user: User) async
}
@MainActor
@Observable
final class HomePresenter: Presenter<HomeViewController>, HomePresentable {
var displayName: String = ""
func presentUser(_ user: User) async {
displayName = "\(user.firstName) \(user.lastName)"
}
}The interactor calls await presenter.presentUser(user) from its actor; the await is the boundary crossing onto the main actor.
In many cases you won't need a separate Presenter class. The simpler pattern — used by the included templates — is to make the view controller conform to the feature-specific Presentable protocol directly. The interactor declares nonisolated let presenter: HomePresentable, calls await presenter.presentUser(user) to send data, and the view controller forwards user events back to the interactor via a PresentableListener protocol whose methods are async.
ViewControllable is the only @MainActor-isolated protocol in napkin. It provides access to the underlying platform view controller:
// UIKit — UIViewController subclasses conform automatically
final class HomeViewController: UIViewController, HomeViewControllable {
// uiviewController returns self via default implementation
}
// SwiftUI — use a UIHostingController
final class HomeHostingController: UIHostingController<HomeView>, HomeViewControllable {
init() {
super.init(rootView: HomeView())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}Define a feature-specific protocol extending ViewControllable for methods the router or presenter needs:
@MainActor protocol HomeViewControllable: ViewControllable {
func displayUserName(_ name: String)
}Routing separates the logical tree (attach/detach) from the visual tree (present/dismiss). The router is @MainActor, so view manipulation runs inline; attachChild / detachChild are async because they activate or deactivate the child interactor on its own actor.
Modal presentation:
func routeToSettings() async {
guard settingsRouter == nil else { return }
let router = await settingsBuilder.build(withListener: interactor)
settingsRouter = router
await attachChild(router)
viewController.uiviewController.present(
router.viewControllable.uiviewController,
animated: true
)
}Push onto a navigation stack:
func routeToDetail(id: String) async {
guard detailRouter == nil else { return }
let router = await detailBuilder.build(withListener: interactor, id: id)
detailRouter = router
await attachChild(router)
let nav = viewController.uiviewController as! UINavigationController
nav.pushViewController(
router.viewControllable.uiviewController,
animated: true
)
}Embed a child view:
func attachDashboard() async {
let router = await dashboardBuilder.build(withListener: interactor)
dashboardRouter = router
await attachChild(router)
let parent = viewController.uiviewController
let child = router.viewControllable.uiviewController
parent.addChild(child)
parent.view.addSubview(child.view)
child.didMove(toParent: parent)
}Viewless napkin (no UI):
func attachAnalytics() async {
guard analyticsRouter == nil else { return }
let router = await analyticsBuilder.build(withListener: interactor)
analyticsRouter = router
await attachChild(router)
}Data flows down the napkin tree; events flow up through listener protocols. Build-time injection covers a child's initial values — but for values that keep changing (auth state, session data, totals), Combine-era napkin put a subject on a service, shared the service through the parent's Component, and let each interested interactor subscribe. That architecture is unchanged: the service is still created once with shared {} and threaded down through Dependency protocols. Only the streaming primitive changes — and state (has a current value) and events (fire-and-forget) get different tools.
| Combine | napkin 2.x | Notes |
|---|---|---|
CurrentValueSubject |
actor service vending replay-latest streams |
Replays current value; a fresh stream per subscriber |
@Published / ObservableObject |
@Observable service + Observations {} |
Multi-consumer; each iterator starts with the current value |
PassthroughSubject |
The same fan-out actor, minus the initial yield |
No replay |
.sink {}.store(in: &cancellables) |
task { for await … } |
Auto-cancelled on deactivate |
.subscribe(presenter.someSubject) |
await presenter.present(…) in the loop body |
Presentable protocols expose async methods, not subjects |
.catch / .retry / subject reset() |
async throws at the call site |
Streams carry state, not failure; they never terminate on error |
.catch { presentError(…); return Just(fallback) } |
do { for try await … } catch { await presenter.presentError(…) } |
Both are terminal — emit a fallback in the catch if you need one |
.map / transforms mid-pipeline |
Plain code in the loop body | It's just a for loop |
.receive(on: DispatchQueue.main) |
await presenter.… |
The presenter is @MainActor; the crossing is explicit |
assign(to:on:) / nested ObservableObject view model |
Set the @Observable presenter property; SwiftUI reads it directly |
The view-model layer disappears |
tapSubject on the SwiftUI view + .sink in the VC |
dispatch { await listener?.didTapX() } |
See SwiftUI Integration |
publisher(for: \.keyPath) (KVO on UIKit objects) |
The UIKit override/callback KVO was wrapping + dispatch {} |
Not every pipe becomes a stream |
combineLatest / merge / debounce / removeDuplicates |
swift-async-algorithms | Official Apple package, not part of the standard library |
The producer side — the half Combine users already wrote themselves and 2.x docs never showed. A service actor owns the current value and fans out to any number of subscribers:
The 0.x version this replaces
// The manager owned a subject; errors terminated it, so the manager
// grew a reset() that swapped in a fresh subject…
protocol AuthenticationManaging {
var userSubject: PassthroughSubject<User?, Error> { get }
func reset() -> AuthenticationManager
func signIn()
func signOut()
}
// …and every subscriber needed catch/retry ceremony to survive:
authenticationManager.userSubject
.catch { error -> PassthroughSubject<User?, Error> in
self.presenter.presentError(error: error)
return self.authenticationManager.reset().userSubject
}
.retry(.max)
.assertNoFailure()
.sink(receiveValue: handleUser)
.store(in: &cancellables)/// Replaces `CurrentValueSubject`: replays the current value to each new
/// subscriber, fans out to any number of subscribers, and never
/// terminates on error. Same shape as the framework's own
/// `isActiveStream`. The actor is the lock — no `Mutex`, no
/// `@unchecked Sendable`.
actor AuthenticationService {
private(set) var currentUser: User?
private var subscribers: [UUID: AsyncStream<User?>.Continuation] = [:]
/// A fresh stream per subscriber: the current value immediately,
/// then every change. `AsyncStream` is single-consumer — vending a
/// new stream per call is what makes fan-out safe.
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
}
/// Errors surface here, at the call site that asked — not on the
/// stream. This is why the Combine version's catch/reset/retry
/// ceremony has no translation: it has no job left.
func signIn(name: String) async throws -> User {
let user = User(name: name) // e.g. try await backend.signIn()
setUser(user)
return user
}
func signOut() async throws {
setUser(nil) // e.g. try await backend.signOut()
}
/// Adapting a callback API — the 0.x manager wrapped an auth
/// SDK's state-change listener; the 2.x service hops the callback
/// onto the actor:
func bind(to auth: AuthClient) {
auth.addStateDidChangeListener { user in
Task { await self.setUser(user) }
}
}
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)
}
}Share it exactly as before — shared {} in the parent's Component, forwarded through child Dependency protocols:
protocol RootDependency: Dependency {}
final class RootComponent: Component<RootDependency>, @unchecked Sendable {
var authService: AuthenticationService {
shared { AuthenticationService() }
}
}
protocol ProfileDependency: Dependency {
var authService: AuthenticationService { get }
}
extension RootComponent: ProfileDependency {}The root napkin's interactor is the auth gate — one lifecycle-bound subscription drives routing:
@MainActor
protocol RootRouting: ViewableRouting, Sendable {
func routeToHome(user: User) async
func routeToLogin() async
}
final actor RootInteractor: Interactable {
nonisolated let lifecycle = InteractorLifecycle()
weak var router: RootRouting?
private let authService: AuthenticationService
init(authService: AuthenticationService) {
self.authService = authService
}
func didBecomeActive() async {
// One long-lived subscription drives routing. Bound to the
// active scope: cancelled automatically on willResignActive.
task {
for await user in await self.authService.userStream() {
if let user {
await self.router?.routeToHome(user: user)
} else {
await self.router?.routeToLogin()
}
}
}
}
}Note what happened to the error handling: nothing replaced it. signIn() is async throws, so failures return to the call site that asked; the stream carries only state and can never terminate on error. The catch/retry/reset() chain isn't translated — it's deleted.
Teardown is a closed loop with no leak path: detaching the napkin cancels the task {}, cancellation fires the stream's onTermination, and onTermination removes the subscriber from the actor's table.
One value crosses four seams on its way to a pixel: service → interactor (task { for await }), interactor → presenter (an awaited async method), presenter → view (a direct @Observable read), and view → interactor (dispatch {}). Here a second napkin, deeper in the tree, subscribes to the same service — each userStream() call is an independent stream, so fan-out just works. Because every stream starts with the current value, a napkin attached after login learns the auth state immediately — an upgrade over the PassthroughSubject original, where late subscribers waited for the next change. It carries the value through the presenter into SwiftUI:
The 0.x version this replaces
// The presentable protocol exposed subjects…
protocol ProfilePresentable: Presentable {
var greeting: PassthroughSubject<String, Never> { get }
}
// …the interactor piped into them…
userManager.userPublisher
.map { user in user.map { "Welcome back, \($0.name)" } ?? "Signed out" }
.subscribe(presenter.greeting)
.store(in: &cancellables)
// …and the hosting controller re-piped into a nested view model:
greeting
.receive(on: DispatchQueue.main)
.assign(to: \.viewModel.greeting, on: rootView)
.store(in: &cancellables)protocol ProfilePresentable: Presentable, Sendable {
func present(greeting: String) async
}
final actor ProfileInteractor: PresentableInteractable {
nonisolated let lifecycle = InteractorLifecycle()
nonisolated let presenter: ProfilePresentable
private let authService: AuthenticationService
init(presenter: ProfilePresentable, authService: AuthenticationService) {
self.presenter = presenter
self.authService = authService
}
func didBecomeActive() async {
task {
// A second, independent stream from the same service: the
// root gate above and this napkin both see every change.
for await user in await self.authService.userStream() {
// What `.map` did mid-pipeline is now plain code.
let greeting = user.map { "Welcome back, \($0.name)" } ?? "Signed out"
// The `await` is the main-actor crossing — this is
// where `.receive(on: DispatchQueue.main)` went.
await self.presenter.present(greeting: greeting)
}
}
}
}The presenter is the view model. Its @Observable stored property replaces the subject, the assign, the nested ObservableObject, and the receive(on: main) — SwiftUI reads it directly. Hold the presenter weakly (it owns the view controller that owns the view), and rebind with @Bindable inside body when you need two-way bindings:
@MainActor
@Observable
final class ProfilePresenter: Presenter<ProfileViewController>, ProfilePresentable {
var greeting: String = ""
func present(greeting: String) async {
self.greeting = greeting
}
}
struct ProfileView: View {
// Weak: the presenter owns the view controller, which owns this view —
// a strong reference here would be a retain cycle. The interactor keeps
// the presenter alive for the napkin's whole attached lifetime.
weak var presenter: ProfilePresenter?
var body: some View {
Text(presenter?.greeting ?? "")
}
}(Presenter's generic argument is your concrete hosting controller — here ProfileViewController, built by the feature's builder. A protocol can't satisfy it: existentials don't conform to their own protocols.)
Three notes from real migrations:
- Many subjects collapse into one presenter. Parallel subjects (
institutions,rank,user) become stored properties on a single presenter — several pipes become several properties, not several streams. Collections included:var institutions: [Institution]drivesForEachdirectly, and per-subview view-model construction disappears (child views take presenter properties as plain values). - Formatting moves into the presenter. 0.x ran
compactMap { currencyFormatter.string(from:) }inside view-controller pipelines; that transform belongs in the presenter method — which is thePresenterclass's stated job. Where the hosting controller also feeds UIKit chrome (a navigation title, a bar-button label), the same presenter serves both: a direct read for SwiftUI,Observations {}for UIKit — see the SwiftUI Integration guide. - Animations.
.transition/.animation(value:)on the view keep working. Where 0.x relied on implicit animation fromobjectWillChange, wrap the mutation inwithAnimationinside the presenter method — it's@MainActor, so this is legal and local.
Fire-and-forget events (no current value, no replay) are the same fan-out actor minus the replay:
enum AuthEvent: Sendable {
case sessionExpired
case passwordChanged
}
/// Replaces `PassthroughSubject`: the same fan-out actor, minus the
/// replay. New subscribers see only events sent after they subscribed.
actor AuthEventBus {
private var subscribers: [UUID: AsyncStream<AuthEvent>.Continuation] = [:]
func events() -> AsyncStream<AuthEvent> {
let (stream, continuation) = AsyncStream.makeStream(of: AuthEvent.self)
let id = UUID()
subscribers[id] = continuation
// No stored current value and no initial yield — the replay
// is the whole difference between CurrentValueSubject and
// PassthroughSubject.
continuation.onTermination = { [weak self] _ in
Task { await self?.removeSubscriber(id) }
}
return stream
}
func send(_ event: AuthEvent) {
for continuation in subscribers.values {
continuation.yield(event)
}
}
private func removeSubscriber(_ id: UUID) {
subscribers[id]?.finish()
subscribers.removeValue(forKey: id)
}
}
// Consumed exactly like state — a lifecycle-bound task:
final actor SessionMonitorInteractor: Interactable {
nonisolated let lifecycle = InteractorLifecycle()
private let eventBus: AuthEventBus
init(eventBus: AuthEventBus) {
self.eventBus = eventBus
}
func didBecomeActive() async {
task {
for await event in await self.eventBus.events() {
await self.handle(event)
}
}
}
private func handle(_ event: AuthEvent) { /* … */ }
}Warning —
AsyncStreamis single-consumer. Concurrent iteration of oneAsyncStreaminstance is a programmer error (SE-0314). Never store one stream and hand it to multiple subscribers — vend a fresh stream per subscriber, as both recipes above do. Combine publishers multicast; streams don't.
/// When state is main-actor-friendly anyway — view-adjacent session
/// state, say — skip the hand-rolled fan-out. An `@Observable` class
/// plus `Observations` gives you `CurrentValueSubject` semantics for
/// free: each iterator starts with the current value, and any number
/// of consumers can observe independently.
@MainActor
@Observable
final class SessionService {
private(set) var currentUser: User?
func set(user: User?) {
currentUser = user
}
}
final actor SettingsInteractor: Interactable {
nonisolated let lifecycle = InteractorLifecycle()
private let sessionService: SessionService
init(sessionService: SessionService) {
self.sessionService = sessionService
}
func didBecomeActive() async {
// The observation loop runs on the actor that owns the state —
// bind it to the main actor, and hop back to this actor to
// handle each value. Still lifecycle-bound: cancelled on
// willResignActive.
let sessionService = self.sessionService
task { @MainActor [weak self] in
for await user in Observations({ sessionService.currentUser }) {
await self?.handle(user)
}
}
}
private func handle(_ user: User?) { /* … */ }
}Observations (SE-0475) is multi-consumer and primes each iterator with the current value — CurrentValueSubject semantics without writing a broadcaster. The trade-off: the state lives on the main actor, which is right for view-adjacent session state and wrong for business state that belongs off it (see Concurrency Model).
Combine's KVO publishers were wrapping UIKit callbacks; 2.x uses the callbacks:
@MainActor
final class ProfileViewController: UIViewController {
weak var listener: ProfilePresentableListener?
// 0.x observed UIKit with Combine's KVO publisher:
//
// publisher(for: \.parent)
// .sink { [weak self] parent in
// if parent == nil { self?.listener?.didDismiss() }
// }
// .store(in: &cancellables)
//
// 2.x uses the UIKit callback that KVO was wrapping:
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent == nil {
dispatch { [listener] in await listener?.didDismiss() }
}
}
}Tap enums with associated values (case institution(itemId:institutionId:)) get the same treatment: they become listener methods with parameters, and the enum-plus-switch ceremony deletes.
For operator-heavy pipelines — combineLatest, merge, debounce, removeDuplicates — reach for swift-async-algorithms, Apple's official package of AsyncSequence algorithms. Migration mechanics beyond streaming live in Migrating from v0; lifecycle binding rules in the lifecycle guide; the task {} vs Task {} decision rules in Cross-Isolation Patterns. This section also lives as a DocC article: Streaming State Down the Tree.
Use LaunchRouter as the root of the napkin tree. Its launch(from:) method is async: it sets the root view controller on the window, activates the interactor, and awaits load(). Hop into a Task { @MainActor in } from the synchronous scene callback:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var launchRouter: LaunchRouting?
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
self.window = window
let component = AppComponent()
let builder = RootBuilder(dependency: component)
Task { @MainActor in
let router = await builder.build(withListener: AppListener())
self.launchRouter = router
await router.launch(from: window)
}
}
}The root router subclasses LaunchRouter:
@MainActor
final class RootRouter: LaunchRouter<RootInteractor, RootViewControllable>,
RootRouting {
private let homeBuilder: HomeBuildable
init(interactor: RootInteractor,
viewController: RootViewControllable,
homeBuilder: HomeBuildable) {
self.homeBuilder = homeBuilder
super.init(interactor: interactor, viewController: viewController)
}
override func didLoad() async {
await super.didLoad()
await routeToHome()
}
func routeToHome() async {
let router = await homeBuilder.build(withListener: interactor)
await attachChild(router)
}
}The simplest viewful pattern — the one the example app and the Xcode templates use — is to make the UIHostingController (or NSHostingController on macOS) conform to the feature's Presentable protocol directly. No separate Presenter class, and no presenter⟷view-controller construction cycle. The view holds a weak reference to the listener and forwards user events through it:
protocol HomePresentableListener: AnyObject, Sendable {
func didTapProfile() async
}
protocol HomePresentable: Presentable, Sendable {
@MainActor var listener: HomePresentableListener? { get set }
}
struct HomeView: View {
weak var listener: HomePresentableListener?
var body: some View {
Button("Profile") {
dispatch { [listener] in await listener?.didTapProfile() }
}
}
}
@MainActor protocol HomeViewControllable: ViewControllable {}
@MainActor
final class HomeViewController: UIHostingController<HomeView>, HomePresentable {
weak var listener: HomePresentableListener? {
didSet { rootView.listener = listener }
}
init() {
super.init(rootView: HomeView())
}
@MainActor required dynamic init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension HomeViewController: HomeViewControllable {}The builder constructs the view controller, hands it to the interactor as the presenter, and wires the tree — no placeholder, no cycle:
@MainActor
func build(withListener listener: HomeListener) async -> HomeRouting {
let component = HomeComponent(dependency: dependency)
let viewController = HomeViewController()
let interactor = HomeInteractor(presenter: viewController,
userService: component.userService)
let router = HomeRouter(interactor: interactor, viewController: viewController)
await interactor.wire(router: router, listener: listener)
return router
}(To render formatted state, give the interactor a nonisolated let presenter and call await presenter.presentUser(...) — the separate @Observable Presenter shown in Core Components.)
When you do want a separate @Observable presenter holding formatted view-state, use the Presenter base class as shown in Core Components — it's parameterized over your concrete view-controller type: build the view controller first, hand it to Presenter's initializer, and let the view hold the presenter weak and read its properties directly. Re-annotate the subclass with @Observable so its stored properties are tracked.
Forward user actions to the interactor with dispatch { await listener?.didTapX() }. The dispatch helper is @MainActor and spawns an unstructured Task to call the actor-isolated listener — it's the bridge from a synchronous SwiftUI button handler to the async listener method:
protocol HomePresentableListener: AnyObject, Sendable {
func didTapProfile() async
}
struct HomeView: View {
weak var presenter: HomePresenter?
weak var listener: HomePresentableListener?
var body: some View {
Button("Profile") {
dispatch { [listener] in await listener?.didTapProfile() }
}
}
}napkin uses Swift Testing. Interactor tests are async; assertions about actor-isolated state require await. Mocks for Sendable listener and @MainActor presentable protocols can be plain final classes with the appropriate isolation:
import Testing
@testable import YourApp
@Suite("HomeInteractor")
struct HomeInteractorTests {
@Test func didTapLogout_notifiesListener() async {
let listener = MockHomeListener()
let presenter = await MockHomePresentable()
let interactor = HomeInteractor(presenter: presenter, userService: MockUserService())
await interactor.wire(router: nil, listener: listener)
await interactor.activate()
await interactor.didTapLogout()
#expect(await listener.logoutCalled)
}
}
final actor MockHomeListener: HomeListener {
private(set) var logoutCalled = false
func homeDidRequestLogout() async { logoutCalled = true }
}
@MainActor
final class MockHomePresentable: HomePresentable {
weak var listener: HomePresentableListener?
var lastUser: User?
func presentUser(_ user: User) async { lastUser = user }
}Run tests via SwiftPM:
swift testOr via Xcode (Command+U after opening Package.swift), or via fastlane:
bundle install
bundle exec fastlane unit_testNapkin's Rib House under Examples/RibHouse is a runnable iOS app demonstrating the framework end-to-end: a headless LaunchNapkin holds an AuthService, swapping between a LoggedOutNapkin (Login button) and a LoggedInNapkin (user name + barbecue list). The app also exercises every recipe from Streaming State Down the Tree, live: the Launch napkin's auth gate routes from userStream(), a PitService actor fans out to the LoggedIn header and a pushed Pit Board napkin, a headless Announcements napkin consumes the no-replay last-call stream, and the specials list arrives via Observations.
Left: LoggedOutNapkin · Center: LoggedInNapkin · Right: PitBoardNapkin
All three screenshots are the reference images from the example's snapshot tests (Examples/RibHouse/SnapshotTests/__Snapshots__/) — any visual regression in any of the three views flips the test red. The .xcodeproj is tracked, so just:
open Examples/RibHouse/RibHouse.xcodeprojWalkthrough: https://getnapkin.to/documentation/napkin/tutorialbuildingaloginflow
napkin includes Xcode templates for creating napkin components from the File > New File... menu.
git clone https://github.com/WikipediaBrown/napkin.git
bash napkin/Tools/InstallXcodeTemplates.sh| Template | Description |
|---|---|
| napkin | Builder, Interactor, Router (+ optional ViewController) |
| Launch napkin | Root napkin for app launch |
| napkin Unit Tests | Interactor and Router test files |
| Component Extension | Component extension for child dependencies |
| Service Manager | Service manager pattern |
napkin releases new versions on GitHub automatically when a pull request is merged from develop to main. The default is a patch bump; for a minor or major release, trigger the Release workflow manually from the Actions tab and choose the bump type.
Notable changes are documented in CHANGELOG.md. The current major version is 2.x (Swift 6.2 native concurrency); see the changelog for migration notes from 0.x / 1.x.
Send a pull request or create an issue. Commits must be signed:
git config commit.gpgsign trueWikipedia Brown
napkin is available under the Apache 2.0 license. See the LICENSE file for more info.
Made with 🌲🌲🌲 in Cascadia


