§ 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 worked with 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. 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 scans the routing tree at deactivation time, looking for detached interactors that didn't release their references. It's clever and useful in a large codebase with many contributors. It also runs in production, fires false positives under certain valid patterns, and 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 cover most of the cases the leak detector watches for. 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 actor instead of subclasses, @MainActor instead of "assume main thread," AsyncStream instead of Observable, weak listeners instead of disposables.
  • No third-party dependencies. The whole framework is plain Swift Concurrency on top of Foundation, UIKit/AppKit, and SwiftUI.

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:

HomeInteractor.swift (RIBs)
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:

HomeInteractor.swift (napkin)
import napkin

final actor HomeInteractor: PresentableInteractable,
                            HomePresentableListener {
    nonisolated let lifecycle = InteractorLifecycle()
    nonisolated let presenter: HomePresentable

    init(presenter: HomePresentable) { self.presenter = presenter }

    func didBecomeActive() async {
        for await user in userStream {
            await 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. The stream consumption is for await ... in; cancellation is the task being torn down when the interactor deactivates.

Routing: @MainActor, explicit await

Routing and presentation touch UIKit, so they're @MainActor. The interactor crosses the boundary with an explicit await:

HomeInteractor.swift
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: weak var listener is enforced; 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 either caught by the compiler or impossible to express.

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 Dependency protocol; 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 view of their own — the parent that owns the LoggedOut/LoggedIn swap in the example app is one.

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, three napkins, ~600 lines of Swift) and the tutorial walks every file. If you've shipped RIBs code, the tutorial will feel familiar — you'll see what's the same and what's been swapped underneath.