§ 02 · 2026 · 05 · 15 · Architecture
A RIBs alternative for Swift Concurrency, without RxSwift: meet napkin
Uber's RIBs is alive, well, and actively developed. It's also built on RxSwift and ships a runtime leak detector. napkin is the same Router-Interactor-Builder pattern written for Swift Concurrency, without either — Clean Architecture on a napkin.
If you've shipped a non-trivial iOS app in the last five years, you've probably at least encountered RIBs. The pattern is simple to describe and hard to fully internalize: every feature is a unit of Router, Interactor, and Builder, composed into a tree. The interactor is the unit's brain; the router owns the subtree; the builder constructs everything. A view, when there is one, lives behind a Presentable protocol that the interactor talks to.
RIBs is good. Uber still maintains it. Teams ship with it every day. This post isn't a "RIBs is dead" pitch — it isn't, and any post that tries to sell you that is selling something.
This post is for a narrower audience: people who want the RIBs pattern but don't want to bring RxSwift along with it. People who'd rather have the compiler enforce structure than have a runtime leak detector. People who like the idea of Clean Architecture but want it expressed in Swift 6's native vocabulary — actors, @MainActor, Sendable, async/await — not in operators and disposables.
Two things RIBs ships that you may not want
RxSwift
RIBs is built on RxSwift — async-stream interfaces are starting to appear, but the core is Rx. Stream protocols are Observable; cancellation goes through DisposeBag; lifecycle hooks fire into Rx pipelines. That's a fine choice and a battle-tested one. It's also a transitive dependency that ships in your binary, has its own learning curve, and pulls in idioms that don't compose neatly with async functions.
If your team is already fluent in RxSwift and your codebase is full of it, this isn't a problem. If you're starting a new app on Swift 6 and you'd rather not add a reactive-streams library on top of the language's built-in concurrency model, it's friction.
The runtime leak detector
RIBs ships a runtime leak detector that registers deallocation expectations when a router is torn down, and asserts if the detached interactor or its view controller is still alive after a time window. It's clever and useful in a large codebase with many contributors. Its tracking machinery ships in release builds — only the assertions compile out — and it's opt-out via a DISABLE_LEAK_DETECTION environment flag. And because the window is fixed, it can fire on valid code that simply deallocates late. It is, fundamentally, a heuristic — a check that your code matched a structure.
Swift 6's strict concurrency model can enforce many of those structural invariants at compile time. Actor isolation, Sendable conformance, and explicit lifecycles address many of the structural mistakes the leak detector exists to catch — and deterministic detachChild / isolated deinit teardown narrows the rest. If you're willing to design within those constraints, you don't need the runtime check — the compiler is the check.
What napkin is
napkin is the RIBs pattern, kept honest:
- Same vocabulary — Builder, Component, Interactor, Router, Presenter, ViewController.
- Same tree composition — parent components conform to children's dependency protocols; intents flow up through listener protocols; routing happens via
attachChild/detachChild. - Different mechanics underneath —
final actorinstead of subclasses,@MainActorinstead of "assume main thread,"AsyncStreaminstead ofObservable, weak listeners instead of disposables. - No third-party dependencies. The whole framework is plain Swift Concurrency on top of Foundation, UIKit/AppKit, and Observation — SwiftUI comes in via hosting controllers in your app code.
The name is the pitch: a napkin is what you sketch Clean Architecture on. The framework is small enough to fit on one.
What napkin changes (concretely)
Interactor: final actor, not subclass
Where a typical RIBs interactor looks like:
import RIBs
import RxSwift
final class HomeInteractor: PresentableInteractor<HomePresentable>,
HomeInteractable,
HomePresentableListener {
override func didBecomeActive() {
super.didBecomeActive()
userStream
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] in self?.presenter.update($0) })
.disposeOnDeactivate(interactor: self)
}
}
napkin's looks like:
import napkin
final actor HomeInteractor: PresentableInteractable,
HomePresentableListener {
nonisolated let lifecycle = InteractorLifecycle()
nonisolated let presenter: HomePresentable
init(presenter: HomePresentable) { self.presenter = presenter }
func didBecomeActive() async {
task {
for await user in await self.userStream {
await self.presenter.update(user)
}
}
}
}
Same intent, different mechanics. final actor replaces final class — business logic runs off the main actor on its own serial executor. PresentableInteractable is a protocol; there's no base class because Swift actors can't be subclassed. nonisolated let lifecycle = InteractorLifecycle() is the protocol-composition trick that gives the actor "inherited" lifecycle hooks via a default extension. The full reasoning is in the docs.
And no RxSwift. Stream consumption is for await ... in inside a lifecycle-bound task {}; when the interactor deactivates, the lifecycle cancels every task it registered.
Routing: @MainActor, explicit await
Routing and presentation touch UIKit, so they're @MainActor. The interactor crosses the boundary with an explicit await:
func didTapProfile() async {
await router?.routeToProfile()
}
Every isolation crossing is an await. The compiler enforces it — no Sendable warnings to suppress, no isolation ceremony to learn around. You start the call with await and stop worrying.
No leak detector
napkin doesn't ship one. The framework relies on Swift 6's compile-time guarantees plus one honest convention: weak var listener is the documented pattern (the compiler won't stop you from holding it strongly — that one's on you); detachChild deactivates the child's lifecycle deterministically; isolated deinit (SE-0371, Swift 6.2) ensures teardown happens on the right executor. Patterns that would have tripped the RIBs leak detector are largely caught by the compiler or discouraged by the API shape.
If you ship without the leak detector and worry about subtle leaks, run Instruments. The point isn't that leaks can't happen — it's that the framework doesn't carry a runtime memory-heuristic to find them.
What napkin keeps
Everything that made RIBs valuable in the first place is still there:
- The Router/Interactor/Builder triad. If you've named a RIBs feature, you can name a napkin.
- Listener protocols for upward communication. Children don't import or reference their parent; they call
listener?.fooDidFinish()and the parent's interactor conforms to the listener. - Component-tree dependency injection. Every feature declares what it needs from above via a
Dependencyprotocol; the parent's component conforms to its children's needs. Compile-time enforced. - The Clean Architecture dependency rule. Business logic doesn't depend on UI; UI depends on protocols, not concrete interactor types.
- Headless napkins. Orchestrator napkins with no presenter or view logic of their own — the root napkin that owns the LoggedOut/LoggedIn swap in the example app is one; its view controller is just an empty container for its children.
When to use what
Use RIBs if you have an existing RIBs codebase that works, your team is fluent in RxSwift, or you specifically want the runtime leak detector. None of those are wrong reasons.
Use napkin if you want the same pattern without the RxSwift dependency, you're starting a new app on Swift 6, or you want to lean on the compiler instead of runtime checks. None of those are wrong reasons either.
Both ship the same architecture pattern. They make different trade-offs underneath. Pick the one whose trade-offs match your team.
Try it
The RibHouse example is a complete tree (auth flow plus live features — five napkins, ~1,700 lines of Swift) and the tutorial walks every file of the login flow. If you've shipped RIBs code, the tutorial will feel familiar — you'll see what's the same and what's been swapped underneath.