Lattice is a Swift 6 library for building features with MVVM + unidirectional data flow. It uses native Swift concurrency and supports iOS 17+, macOS 14+, and watchOS 10+.
- Unidirectional flow: views send actions, interactors mutate domain state, reducers derive view state.
- Feature-based API:
ViewModelis parameterized by a single feature type (ViewModel<F>). - Async effects:
.none,.action,.perform,.observe,.merge, and.appendemissions. - Effect-level debouncing:
Emission.debounce(using:)/Debouncer, andInteractors.Debouncefor one-shot.performeffects. - Interactor composition:
Interactors.When,when(state:action:child:),Merge, andMergeMany. - View composition:
scope(state:action:)andScopedViewModelproject fine-grained child slices of view state, including enum-case payloads viascopeIfActive. - SwiftUI integration:
@ObservableState,@Bindable, dynamic member lookup, andEventTask. - Step-wise testing:
TestViewModel,TestEventTask, exhaustivity, and clock-based testing support.
Add package dependency:
dependencies: [
.package(url: "https://github.com/mibattaglia/swift-lattice", from: "0.1.0")
]Add product dependency:
.target(
name: "MyApp",
dependencies: ["Lattice"]
)struct CounterDomainState: Sendable, Equatable {
var count = 0
}
enum CounterAction: Sendable {
case increment
case decrement
}import Lattice
@Interactor<CounterDomainState, CounterAction>
struct CounterInteractor: Sendable {
var body: some InteractorOf<Self> {
Interact { state, action in
switch action {
case .increment:
state.count += 1
case .decrement:
state.count -= 1
}
return .none
}
}
}@ObservableState
struct CounterViewState: Sendable, Equatable, DefaultValueProvider {
static let defaultValue = CounterViewState()
var countText = "0"
}
@ViewStateReducer<CounterDomainState, CounterViewState>
struct CounterViewStateReducer: Sendable {
var body: some ViewStateReducerOf<Self> {
BuildViewState { domainState, viewState in
viewState.countText = String(domainState.count)
}
}
}import SwiftUI
struct CounterView: View {
@State private var viewModel = ViewModel(
initialDomainState: CounterDomainState(),
feature: Feature(
interactor: CounterInteractor(),
reducer: CounterViewStateReducer()
)
)
var body: some View {
VStack {
Text(viewModel.viewState.countText)
HStack {
Button("-") { viewModel.sendViewEvent(.decrement) }
Button("+") { viewModel.sendViewEvent(.increment) }
}
}
}
}If domain state and view state are the same type, initialize Feature with only an interactor:
let viewModel = ViewModel(
initialDomainState: CounterDomainState(),
feature: Feature(interactor: CounterInteractor())
)Customize domain-state equality when state is not Equatable or when identity-based comparisons are preferred:
let feature = Feature(
interactor: CounterInteractor(),
reducer: CounterViewStateReducer(),
areStatesEqual: { lhs, rhs in lhs.version == rhs.version }
)- The view sends an action via
sendViewEvent(_:). ViewModelapplies the synchronous interactor step immediately on the main actor.- The interactor mutates domain state and returns an
Emission<Action>. - A stateless
ViewStateReducerupdatesviewStatefrom domain state. - Async emissions spawn tasks and can dispatch more actions back into the same root send scope, whether they are concurrent (
.merge) or sequential (.append). EventTask.finish()waits transitively for that root scope to become quiescent, andcancel()cancels the currently tracked work in the scope.
Lattice separates DomainState from ViewState to make tests more expressive and decoupled from SwiftUI, make debugging simpler, and enforce clean boundaries.
DomainState: the business-logic model for a feature. It can include raw values (Date, IDs), workflow state, and external models when they are domain-aligned.ViewState: rendering instructions only. Think strings, colors, visibility flags, and composed presentation models.ViewStateReducer: the translation boundary. It is synchronous and stateless, and boils domain data into presentation-ready values.
Views and view controllers should render state and send actions. Formatting logic, complex branching, and business rules should stay out of the rendering layer.
- Why: it keeps UI tests focused on rendering, keeps business logic testable without SwiftUI, and reduces debugging surface area.
- Rule: if a value needs formatting for display, reduce it before it reaches the view.
- Raw
Dateor unformatted numeric values that the UI must interpret. - API/DB DTOs (unless they already are presentation models).
- Business-rule-only state that never affects rendering.
- Views: send actions and render
ViewState. - Interactors: mutate
DomainStateand connect to external systems (APIClient,DBClient, etc.) via dependencies. - Reducers: convert domain data to display language.
- Flow: view action -> interactor mutation/effect -> domain update -> reducer projection -> render.
For BFF/server-driven or inert UI features, the lightweight Feature(interactor:) path is valid when DomainState == ViewState.
import Foundation
import Lattice
struct CounterAPIModel: Codable, Sendable, Equatable {
let count: Int
let updatedAt: Date
}
struct CounterDomainState: Sendable, Equatable {
var count = 0
var lastUpdatedAt: Date?
}
@ObservableState
struct CounterViewState: Sendable, Equatable, DefaultValueProvider {
static let defaultValue = CounterViewState()
var title = "Counter"
var countText = "0"
var lastUpdatedText = "Never"
}
enum CounterAction: Sendable {
case task
case hydrated(CounterAPIModel)
}
protocol CounterClient: Sendable {
func fetch() async throws -> CounterAPIModel
}
@Interactor<CounterDomainState, CounterAction>
struct CounterInteractor: Sendable {
let counterClient: CounterClient
var body: some InteractorOf<Self> {
Interact { state, action in
switch action {
case .task:
return .perform { [counterClient] in
let model = try await counterClient.fetch()
return .hydrated(model)
}
case .hydrated(let model):
state.count = model.count
state.lastUpdatedAt = model.updatedAt
return .none
}
}
}
}
@ViewStateReducer<CounterDomainState, CounterViewState>
struct CounterViewStateReducer: Sendable {
var body: some ViewStateReducerOf<Self> {
BuildViewState { domainState, viewState in
viewState.countText = "\(domainState.count)"
viewState.lastUpdatedText = Self.renderLastUpdated(domainState.lastUpdatedAt)
}
}
static func renderLastUpdated(_ date: Date?) -> String {
guard let date else { return "Never" }
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter.localizedString(for: date, relativeTo: .now)
}
}Use @Bindable with case-path actions:
@CasePathable
enum FormAction: Sendable {
case nameChanged(String)
}
@ObservableState
struct FormViewState: Sendable, Equatable {
var name = ""
}
@Bindable var viewModel: ViewModel<Feature<FormAction, FormDomainState, FormViewState>>
TextField("Name", text: $viewModel.name.sending(\.nameChanged))For CasePathable enum view state, bindings can be scoped to case members:
$viewModel.detail.title.sending(\.detailTitleChanged, default: "")Use sending(_:default:) when the view may access a binding while the state is in a different case.
Interactors.Debounce preserves immediate synchronous state updates, then debounces top-level one-shot .perform emissions with action-ordered cancel-in-flight behavior, closer to TCA's sleep + cancellable(id:cancelInFlight:) model.
import Clocks
@Interactor<SearchState, SearchAction>
struct SearchInteractor: Sendable {
let searchClient: SearchClient
let debouncer = Debouncer<ContinuousClock, SearchAction?>(for: .milliseconds(300), clock: .init())
var body: some InteractorOf<Self> {
Interact { state, action in
switch action {
case .queryChanged(let query):
state.query = query
return .perform { [searchClient] in
let results = await searchClient.search(query)
return .searchResponse(results)
}
.debounce(using: debouncer)
case .searchResponse:
return .none
}
}
}
}For lower-level emission debouncing, Debouncer and Emission.debounce(using:) still exist separately.
Wrap a child interactor when you want the same runtime behavior around a feature:
Interactors.Debounce(for: .milliseconds(300)) {
SearchInteractor()
}Scope child features with case paths or key paths:
parentInteractor.when(state: \.childState, action: \.child) {
ChildInteractor()
}ViewModel.scope(state:action:) projects a parent view model onto a child slice of view state and a child action space, so child views depend only on their own ScopedViewModel<ChildState, ChildAction> instead of the parent's ViewModel type.
@CasePathable
enum DashboardAction: Sendable {
case header(HeaderAction)
case footer(FooterAction)
}
struct DashboardView: View {
@State private var viewModel: ViewModel<DashboardFeature>
var body: some View {
VStack {
HeaderView(model: viewModel.scope(state: \.header, action: \.header))
FooterView(model: viewModel.scope(state: \.footer, action: \.footer))
}
}
}
struct HeaderView: View {
let model: ScopedViewModel<HeaderViewState, HeaderAction>
var body: some View {
Text(model.title) // fine-grained: re-renders only when `title` changes
Button("Refresh") { model.sendViewEvent(.refreshTapped) }
}
}ScopedViewModel is a stateless value type: it owns no state, effects, or lifecycle, so it is cheap to recreate on every render. Reads walk the parent's live @ObservableState getter chain, so child views observe fine-grained; sends embed into the parent action and return the parent's EventTask.
- Read members through the scope (
model.title,model.badge.count) for fine-grained observation. Reading the wholemodel.viewStatevalue is coarse: it registers only the slice's identity and re-renders only on wholesale replacement. - Create scopes inline in
body; do not store them in@Stateor long-lived properties (a scope retains its parent view model). - Overloads: case-path action embedding (shown above), a closure-based
scope(state:action:)for non-CasePathableactions, and a read-onlyscope(state:)whose action type isNever. - Scopes compose:
ScopedViewModel.scope(state:action:)projects a grandchild slice through the parent. - Two-way bindings:
model.binding(\.name, sending: \.nameChanged).
When view state is a CasePathable enum, scope onto the active case's payload:
switch viewModel.viewState {
case .loading:
LoadingView()
case .success:
SuccessView(model: viewModel.scope(state: \.success, action: \.success))
}scope(state:action:) traps with fatalError if the case is not active; inside a matched switch case this cannot happen because body evaluation is synchronous. Use scopeIfActive(state:action:) when the case may legitimately be inactive:
if let success = viewModel.scopeIfActive(state: \.success, action: \.success) {
SuccessView(model: success)
}Reads through a case scope are live — in-place payload mutations are observed fine-grained — and the scope serves a creation snapshot for at most one transitional render if the case flips while the view is still on screen. Sends that arrive after a case flip should be dropped by the interactor.
See ExampleProject/ScopedCompositionExamplePackage for a runnable demo of both styles, and specs/scoped-view-composition.md / specs/enum-case-scoping.md for design details.
Use TestViewModel<F> for domain-state-first, step-wise feature tests. Its assertion APIs are
non-throwing, so you call them without try.
let feature = Feature(interactor: SearchInteractor())
let model = TestViewModel(
initialDomainState: SearchState(),
feature: feature
)
let task = await model.send(.queryChanged("lattice")) {
$0.query = "lattice"
$0.isLoading = true
}
await model.receive(.searchResponse(["Lattice"])) {
$0.isLoading = false
$0.results = ["Lattice"]
}
await task.finish()TestViewModel semantics:
sendasserts the immediately visible state mutation and returns aTestEventTaskfor that root send scope.domainStatealways reflects the last asserted or received state, not newer buffered emission output.- Actions emitted from emissions are buffered until you
receiveorskipReceivedActions(). finish()checks for unhandled receives before waiting for in-flight emission work.TestEventTask.finish()waits for root-scope quiescence only; it does not implicitly drain buffered receives.skipInFlightEffects()cancels and settles currently running emission work when a test needs to move past long-lived work.exhaustivityis on by default and enforces explicit handling of buffered receives.
Run all tests:
swift testRun library tests only:
swift test --filter LatticeTestsRun macro tests only:
swift test --filter LatticeMacrosTestsRun focused presentation tests:
swift test --filter FeatureViewModelTests
swift test --filter ViewModelBindingTests
swift test --filter ViewModelTests
swift test --filter ScopedViewModelTests
swift test --filter EnumCaseScopingTestsRun focused runtime and testing-infrastructure suites:
swift test --filter EventTaskTests
swift test --filter TestViewModel
swift test --filter Append
swift test --filter ObserveRun focused debounce tests:
swift test --filter EmissionDebounceTests
swift test --filter DebounceInteractorTestsBuild all targets:
swift buildFormatting is handled by the pre-push hook with swift-format. Do not run swift-format manually.
Rebuild checked-in macro binary after macro source changes:
scripts/rebuild-macro.shSet SKIP_LATTICE_MACRO_BUILD=1 or SKIP_LATTICE_MACRO_BUILD=true to skip macro build steps when needed.
Sync local Codex and Claude skill folders:
scripts/sync-skills.shSources/Lattice: runtime library (interactors, view model, emissions, testing helpers).Sources/LatticeMacros: macro implementations.Macros/: checked-in macro tool binary for tooling/Xcode.ExampleProject/: sample app and package-based examples.Tests/: library and macro tests.