A lightweight, opinionated micro-framework for building SwiftUI screens with a clean separation of
concerns: declarative Views, a passive @Observable ViewModel, and an Interactor that
holds all the imperative logic. Views stay free of async/Task; the Interactor owns side effects,
task gating, cancellation, and navigation intents.
- Zero dependencies. Pure Swift + SwiftUI.
@Observable-native. Fine-grained invalidation, no Combine.- Concurrency-safe. Everything is
@MainActor; tasks are gated and cancelled on teardown.
| Platform | Minimum |
|---|---|
| iOS | 17 |
| macOS | 14 |
| tvOS | 17 |
| watchOS | 10 |
| visionOS | 1 |
Swift 6.3 / Xcode 26. Some presentation styles are platform-gated (see Navigation).
Swift Package Manager:
dependencies:[.package(url:"https://github.com/thomasalbert1993/CleanKit", from:"1.0.0")]Then add "CleanKit" to your target's dependencies.
| Type | Role |
|---|---|
ViewModel | Passive, observable state a View renders (protocol). |
NavigableViewModel | A ViewModel that can emit navigation intents. |
Interactor<V> | Owns the logic: async tasks, error handling, navigation, loading. |
IntentPresentation | How the View presents a navigation destination. |
Loadable<T> | The load state of one piece of content. |
LoadableView | Renders a Loadable with sensible, overridable defaults. |
import CleanKit
enumHomeDestination{case detail(id:Int)}@Observable@MainActorfinalclassHomeViewModel:NavigableViewModel{varnavigation:NavigationIntent<HomeDestination>?varfeed:Loadable<[Post]>=.idle // per-content load state
}Declare view models as
@Observableclasses. CleanKit uses protocols (not a base class) because@Observabledoes not track stored properties added in a subclass.
@MainActorfinalclassHomeInteractor:Interactor<HomeViewModel>{
// Inject whatever you like via a custom init — CleanKit is DI-agnostic.
privateletrepository:PostRepositoryinit(viewModel:HomeViewModel, repository:PostRepository){self.repository = repository
super.init(viewModel: viewModel)}
// Called once, on the bound view's first appearance.
overridefunc prepare(){
super.prepare()loadFeed()}func loadFeed(){load(\.feed){tryawait repository.fetchPosts()}}func openDetail(_ id:Int){navigate(to:.detail(id: id))}}structHomeView:View{@Stateprivatevarinteractor=HomeInteractor(
viewModel:HomeViewModel(),
repository:LivePostRepository())varbody:someView{NavigationStack{LoadableView(interactor.viewModel.feed){ posts inList(posts){ post inButton(post.title){ interactor.openDetail(post.id)}}} empty:{ContentUnavailableView("No posts", systemImage:"tray")}.navigationTitle("Home")}.bind(interactor){ destination inswitch destination {case.detail(let id):.push {DetailView(id: id)}}}}}.bind(interactor) calls prepare() once and wires navigation. Use .bind(interactor, onNavigation:)
when the view model is a NavigableViewModel.
Both bind overloads accept an optional setup: closure, run after the interactor is initialised
but beforeprepare() — on the bound view's first appearance. It receives the concrete
interactor type, so you can configure subclass-specific members that aren't available at init time.
The typical case is a View exposing a callback (e.g. onSelect) that only exists once the View is
composed by its parent — too late for the interactor's initializer, but exactly what setup: is for:
structItemListView:View{@Stateprivatevarinteractor=ItemListInteractor(viewModel:ItemListViewModel())
// Provided by the parent view.
letonSelect:(Item)->Voidvarbody:someView{List(interactor.viewModel.items){ item inButton(item.title){ interactor.select(item)}}.bind(interactor){ interactor in
interactor.onSelect = onSelect // wired before prepare()
}}}setup: composes with navigation too: .bind(interactor, setup: { … }, onNavigation: { … }).
Interactors expose synchronous methods that kick off async work internally:
func submit(){asyncTask{tryawait api.submit(form)}
// On failure the error is routed to the interactor's error handlers (see below).
// Guarded by the interactor's busy flag: a second call while busy is ignored.
}Variants:
asyncTask(_:onFailure:finally:)— gated by the interactor's ambient busy flag.asyncTask(gate:)/asyncTask(gateKey:)— gated by a customTaskGate.performThrowable(_:onFailure:)— synchronous throwing work.
Every task is cancelled automatically when the interactor is torn down (or via cancelTasks()).
Cancellation is cooperative — long work should honour Task.isCancelled / use cancellation-aware APIs.
A cancelled task reports .cancelled and does not surface an error.
Errors and the busy state are surfaced through closures, not view model properties — the view model
is a plain marker (AnyObject, Observable). Each has a two-tier chain: a per-bind handler that
takes priority, and an ambient handler scoped to the view subtree.
// Per-bind: this screen handles its own error / spinner.
.bind(interactor,
onError:{ error inshowToast(error); returntrue}, // return true = handled, stop
onBusy:{ isBusy = $0 })
// Ambient: a fallback for every interactor bound below, e.g. at the app root.
RootView().onInteractorError{ error inlog(error)}.onInteractorBusy{showGlobalOverlay($0)}- Errors — the per-bind
onErrorreturnsBool:truemarks it handled and stops;falsefalls through to the ambientonInteractorError. If neither handles it, the error is dropped. - Busy — driven by
asyncTask(_:onFailure:finally:). When present, per-bindonBusytakes over; otherwise the ambientonInteractorBusyfires. Bridge theBoolinto your own@Statefor display.
Loadable<T> models one piece of content, independently of the interactor's error and busy handlers:
varfeed:Loadable<[Post]>=.idleDrive it with load(_:), which manages .loading → .loaded / .failed, captures the error in the
Loadable (not through the error handlers), gates per key path, and resets to .idle if cancelled:
func loadFeed(){load(\.feed){tryawait repository.fetchPosts()}}Render it with LoadableView — you only provide the loaded content; loading and failed have
defaults, and empty: is available for collections:
LoadableView(vm.feed){ posts inList(posts){PostRow(post: $0)}} loading:{ProgressView()} failed:{ error inText(error.localizedDescription)} empty:{ContentUnavailableView("No posts", systemImage:"tray")}The view model emits an intent (a destination); the View decides how to present it via
IntentPresentation:
.bind(interactor){ destination inswitch destination {case.detail(let id):.push {DetailView(id: id)}case.settings:.sheet {SettingsView()}case.confirmDelete:.alert("Delete?"){Button("Delete", role:.destructive){…}}}}Available styles: .push, .sheet, .alert, .confirmationDialog, .perform, plus platform-gated
.fullScreenCover (not macOS), .quickLook (where QuickLook is available), and .fileImporter
(iOS/macOS/visionOS).
navigate(to:) returns a token you can observe — driven automatically by the destination's SwiftUI
lifecycle:
lettoken=navigate(to:.detail(id: id))observe(token,.didAppear){ analytics.log("detail_shown")}observe(token,.minimumExposure(2)){ analytics.log("detail_impression")} // visible ≥ 2s
observe(token,.didDisappear){ analytics.log("detail_closed")}.minimumExposure(x) fires as soon as the destination has stayed on screen for x seconds, and
never fires if it leaves earlier.
CleanKit is DI-agnostic — nothing to configure. Interactors are plain classes, so use whatever you already use:
- Constructor injection (as in the example above), or
- swift-dependencies, Factory, etc. —
e.g.
@Dependency(\.repository) var repositoryworks inside an interactor with no CleanKit support.
MIT — see LICENSE.